merge: quick/content-hash-sources-infra-functions — content hash, sources, funciones infra/core/PowerShell y app navegador

This commit is contained in:
2026-03-30 14:25:18 +02:00
82 changed files with 3826 additions and 48 deletions
+13
View File
@@ -49,6 +49,19 @@ sqlite3 registry.db ".schema"
**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero.
### Schema rapido
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
- Enums: `algebraic`(product|sum)
**FTS5 (columnas buscables):**
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
---
## Estructura
+1
View File
@@ -14,3 +14,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 08 | [tag_launcher.md](tag_launcher.md) | Tag launcher para Pipeline Launcher TUI |
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
+49
View File
@@ -0,0 +1,49 @@
## Extraccion de funciones desde repos externos (`sources/`)
### Workflow
1. Clonar repo en `sources/<nombre>` (gitignored, solo el manifest `sources/sources.yaml` se versiona)
2. El agente analiza el repo y propone funciones candidatas
3. Las funciones se **copian y adaptan** al formato del registry (.go/.py/.sh/.ts + .md con frontmatter)
4. `fn index` las registra. El manifest se actualiza con las funciones extraidas.
### Filtro de calidad (obligatorio antes de extraer)
Una funcion externa solo se extrae si cumple TODOS estos criterios:
- **Firma generica**: no depende de tipos internos del repo origen ni de config hardcodeada
- **Sin estado global**: no usa variables globales, singletons, ni init() con side effects
- **Dependencias minimas**: solo stdlib o dependencias ya presentes en fn_registry
- **Pura si es posible**: si la funcion puede ser pura, debe extraerse como pura
- **Sin credenciales**: no contiene secrets, API keys, ni paths absolutos
- **Testeable**: la logica debe poder validarse con tests unitarios
- **No duplicada**: consultar registry.db con FTS5 antes de extraer para evitar duplicados
- **Licencia compatible**: el repo debe tener licencia permisiva (MIT, Apache 2.0, BSD, etc.)
### Adaptacion al extraer
- Renombrar a snake_case siguiendo la convencion del registry
- Adaptar firma para usar tipos nativos (no tipos internos del repo)
- Crear .md con frontmatter completo incluyendo `source_repo`, `source_license`, `source_file`
- Actualizar `sources/sources.yaml` con la extraccion
### Campos de atribucion en frontmatter
```yaml
source_repo: "https://github.com/user/project"
source_license: "MIT"
source_file: "pkg/original_file.go"
```
Estos campos se indexan en registry.db y permiten consultar:
```sql
SELECT id, source_repo, source_license FROM functions WHERE source_repo != '';
```
### Lenguajes soportados para extraccion
Cualquier lenguaje puede analizarse como fuente. El destino depende de la naturaleza de la funcion:
- Algoritmos/logica pura → Go (functions/{domain}/) o Python (python/functions/{domain}/)
- Scripts/utilidades sistema → Bash (bash/functions/{domain}/)
- UI/frontend → TypeScript (frontend/functions/{domain}/)
- C/Rust/otros → Traducir a Go o Python, manteniendo la semantica original
+3
View File
@@ -37,6 +37,9 @@ python/.venv/
# Node / pnpm
**/node_modules/
# Sources — repos externos clonados (solo se versiona el manifest)
sources/*/
# OS
.DS_Store
Thumbs.db
@@ -0,0 +1,195 @@
"""Crea un dashboard en Metabase para monitorear operations de script_navegador."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase.client import metabase_auth
from metabase import (
metabase_list_databases,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
metabase_list_dashboards,
)
from metabase.databases import metabase_add_database
# --- Config ---
METABASE_URL = "http://localhost:3000"
EMAIL = "admin@fnregistry.local"
PASSWORD = "FnRegistry2024!"
# Path de operations.db dentro del contenedor Docker
# Copiar con: docker exec metabase mkdir -p /data/ops-script-navegador && docker cp apps/script_navegador/operations.db metabase:/data/ops-script-navegador/operations.db
OPS_DB_PATH = "/data/ops-script-navegador/operations.db"
DB_NAME = "ops-script-navegador"
CARDS = [
# ---- Fila 0: KPIs (h=5) ----
{
"name": "Total Ejecuciones",
"display": "scalar",
"sql": "SELECT COUNT(*) AS total FROM executions;",
"size_x": 6, "size_y": 5, "col": 0, "row": 0,
},
{
"name": "Ejecuciones Exitosas",
"display": "scalar",
"sql": "SELECT COUNT(*) AS exitosas FROM executions WHERE status = 'success';",
"size_x": 6, "size_y": 5, "col": 6, "row": 0,
},
{
"name": "Ejecuciones Fallidas",
"display": "scalar",
"sql": "SELECT COUNT(*) AS fallidas FROM executions WHERE status = 'failure';",
"size_x": 6, "size_y": 5, "col": 12, "row": 0,
},
{
"name": "Duracion Promedio (ms)",
"display": "scalar",
"sql": "SELECT ROUND(AVG(duration_ms)) AS avg_ms FROM executions WHERE status = 'success';",
"size_x": 6, "size_y": 5, "col": 18, "row": 0,
},
# ---- Fila 5: Tendencias (h=8) ----
{
"name": "Ejecuciones por Estado",
"display": "pie",
"sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status;",
"size_x": 8, "size_y": 8, "col": 0, "row": 5,
},
{
"name": "Duracion por Ejecucion (timeline)",
"display": "line",
"sql": """
SELECT
started_at,
duration_ms,
status
FROM executions
ORDER BY started_at;
""",
"size_x": 16, "size_y": 8, "col": 8, "row": 5,
},
# ---- Fila 13: Detalle de pasos (h=9) ----
{
"name": "Pasos por Script (metricas)",
"display": "table",
"sql": """
SELECT
id,
status,
records_in AS pasos_total,
records_out AS pasos_exitosos,
duration_ms,
CASE WHEN error = '' THEN '-' ELSE error END AS error,
json_extract(metrics, '$.script_name') AS script,
started_at
FROM executions
ORDER BY started_at DESC
LIMIT 20;
""",
"size_x": 24, "size_y": 9, "col": 0, "row": 13,
},
# ---- Fila 22: Logs (h=9) ----
{
"name": "Logs Recientes",
"display": "table",
"sql": """
SELECT
level,
source,
message,
json_extract(metadata, '$.action') AS action,
json_extract(metadata, '$.elapsed_ms') AS elapsed_ms,
created_at
FROM logs
ORDER BY created_at DESC
LIMIT 50;
""",
"size_x": 24, "size_y": 9, "col": 0, "row": 22,
},
]
DASHBOARD_NAME = "script_navegador Operations"
def main():
print("Autenticando en Metabase...")
client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
# Buscar si ya existe la database
dbs = metabase_list_databases(client)
ops_db_id = None
for db in dbs:
if db.get("name") == DB_NAME:
ops_db_id = db["id"]
print(f" Database ya existe: {DB_NAME} (id={ops_db_id})")
break
if not ops_db_id:
print(f"Registrando {DB_NAME} como datasource SQLite ({OPS_DB_PATH})...")
new_db = metabase_add_database(
client=client,
name=DB_NAME,
engine="sqlite",
details={"db": OPS_DB_PATH},
)
ops_db_id = new_db["id"]
print(f" Database registrada: id={ops_db_id}")
# Eliminar dashboard existente si lo hay
existing = metabase_list_dashboards(client)
for d in existing:
if d.get("name") == DASHBOARD_NAME:
print(f" Dashboard ya existe (id={d['id']}), recreando...")
from metabase import metabase_delete_dashboard
metabase_delete_dashboard(client, d["id"])
# Crear cards
print("Creando cards...")
created_cards = []
for i, card_def in enumerate(CARDS):
card = metabase_create_card(
client,
name=card_def["name"],
dataset_query={
"database": ops_db_id,
"type": "native",
"native": {"query": card_def["sql"]},
},
display=card_def["display"],
description=f"script_navegador: {card_def['name']}",
)
created_cards.append((card, card_def))
print(f" [{i+1}/{len(CARDS)}] {card_def['name']} (id={card['id']})")
# Crear dashboard
print("Creando dashboard...")
dashboard = metabase_create_dashboard(
client,
name=DASHBOARD_NAME,
description="Monitoreo de ejecuciones de script_navegador: KPIs, tendencias, detalle de pasos y logs.",
)
dash_id = dashboard["id"]
print(f" Dashboard creado: id={dash_id}")
# Agregar cards al dashboard
dashcards = []
for idx, (card, card_def) in enumerate(created_cards):
dashcards.append({
"id": -(idx + 1),
"card_id": card["id"],
"size_x": card_def["size_x"],
"size_y": card_def["size_y"],
"col": card_def["col"],
"row": card_def["row"],
})
metabase_update_dashboard(client, dash_id, dashcards=dashcards)
print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")
client.close()
if __name__ == "__main__":
main()
+2 -2
View File
@@ -11,7 +11,7 @@ Via variables de entorno:
METABASE_URL=http://localhost:3000 \
METABASE_ADMIN_EMAIL=admin@example.com \
METABASE_ADMIN_PASSWORD=secret \
REGISTRY_DB_PATH=/registry.db \
REGISTRY_DB_PATH=/data/registry/registry.db \
python main.py
Via argumentos CLI:
@@ -357,7 +357,7 @@ def build_parser() -> argparse.ArgumentParser:
# Registry DB path (ruta dentro del contenedor Docker)
p.add_argument(
"--registry-db-path",
default=os.environ.get("REGISTRY_DB_PATH", "/registry.db"),
default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"),
dest="registry_db_path",
help=(
"Ruta al registry.db DENTRO del contenedor Docker "
+6
View File
@@ -0,0 +1,6 @@
operations.db
operations.db-wal
operations.db-shm
build/
*.exe
script_navegador
+100
View File
@@ -0,0 +1,100 @@
---
name: script_navegador
lang: go
domain: infra
description: "Ejecutor de scripts de navegador CDP sobre Chrome. Lee pasos desde YAML y los ejecuta en secuencia registrando cada resultado en operations.db."
tags: [cdp, chrome, browser, automation, yaml]
uses_functions:
- chrome_launch_go_infra
- cdp_connect_go_infra
- cdp_navigate_go_infra
- cdp_click_go_infra
- cdp_type_text_go_infra
- cdp_wait_element_go_infra
- cdp_evaluate_go_infra
- cdp_get_html_go_infra
- cdp_screenshot_go_infra
- cdp_close_go_infra
uses_types: []
framework: ""
entry_point: "main.go"
dir_path: "apps/script_navegador"
---
## Descripcion
CLI Go que lee un archivo YAML con pasos de navegacion CDP y los ejecuta sobre Chrome, registrando cada paso y su resultado en `operations.db`.
## Uso
```bash
# Conectarse a Chrome ya corriendo en puerto 9222
go run . --script examples/busqueda_google.yaml
# Lanzar Chrome nuevo (headless)
go run . --script examples/busqueda_google.yaml --launch --headless
# Puerto personalizado
go run . --script examples/busqueda_google.yaml --port 9333
```
## Formato del script YAML
```yaml
name: "nombre_del_script"
steps:
- action: navigate
url: "https://ejemplo.com"
- action: wait
selector: "#elemento"
timeout_ms: 5000 # opcional, default 10000
- action: click
selector: "#boton"
continue_on_error: true # opcional, default false
- action: type
selector: "input[name=q]" # hace click primero para enfocar
text: "texto a escribir"
- action: screenshot
path: "/tmp/captura.png"
full_page: false # opcional, default false
- action: evaluate
expr: "document.title"
- action: get_html
# sin parametros adicionales
- action: sleep
ms: 500 # pausa en milisegundos
```
## Acciones soportadas
| Accion | Parametros obligatorios | Parametros opcionales |
|-------------|-------------------------|-------------------------------|
| `navigate` | `url` | |
| `wait` | `selector` | `timeout_ms` (default 10000) |
| `click` | `selector` | `continue_on_error` |
| `type` | `selector`, `text` | `continue_on_error` |
| `screenshot`| `path` | `full_page`, `continue_on_error` |
| `evaluate` | `expr` | `continue_on_error` |
| `get_html` | — | `continue_on_error` |
| `sleep` | `ms` | |
## Registro en operations.db
- **Entity `script_run`**: una por ejecucion del script, con metadata del script y resultado final
- **Execution**: una por ejecucion, con `pipeline_id = "script_navegador"`, duration_ms, records_in=pasos totales, records_out=pasos exitosos
- **Logs**: un log por cada paso ejecutado con nivel info/error
## Notas
- Si Chrome no esta corriendo y no se pasa `--launch`, la conexion falla con error claro
- `continue_on_error: true` por paso permite continuar aunque ese paso falle
- Flag global `--abort-on-error` (default false) aborta todo el script al primer error
- Al terminar (exito o error), siempre se ejecuta `cdp_close` para limpiar recursos
- operations.db se inicializa automaticamente si no existe usando `fn ops init`
@@ -0,0 +1,28 @@
name: "busqueda_google"
steps:
- action: navigate
url: "https://www.google.com"
- action: wait
selector: "textarea[name=q]"
timeout_ms: 8000
- action: type
selector: "textarea[name=q]"
text: "golang cdp automation"
- action: screenshot
path: "/tmp/busqueda_antes.png"
- action: evaluate
expr: "document.title"
- action: sleep
ms: 500
- action: evaluate
expr: "document.querySelector('textarea[name=q]').value"
- action: screenshot
path: "/tmp/busqueda_despues.png"
full_page: false
@@ -0,0 +1,20 @@
name: "demo_continue_on_error"
steps:
- action: navigate
url: "https://example.com"
- action: wait
selector: "h1"
timeout_ms: 5000
# Este paso fallara porque el selector no existe, pero el script continua
- action: click
selector: "#boton-que-no-existe"
continue_on_error: true
# Este paso se ejecuta aunque el anterior fallo
- action: evaluate
expr: "document.title"
- action: screenshot
path: "/tmp/continue_on_error.png"
@@ -0,0 +1,16 @@
name: "scrape_titulo"
steps:
- action: navigate
url: "https://example.com"
- action: wait
selector: "h1"
timeout_ms: 5000
- action: evaluate
expr: "document.querySelector('h1').textContent"
- action: get_html
- action: screenshot
path: "/tmp/example_com.png"
@@ -0,0 +1,6 @@
name: "navegar_youtube"
steps:
- action: navigate
url: "https://www.youtube.com"
- action: screenshot
path: "/tmp/youtube.png"
+12
View File
@@ -0,0 +1,12 @@
module script-navegador
go 1.24.2
require (
fn-registry v0.0.0
gopkg.in/yaml.v3 v3.0.1
)
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
replace fn-registry => /home/lucas/fn_registry
+6
View File
@@ -0,0 +1,6 @@
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"crypto/rand"
"fmt"
)
// generateID genera un UUID v4 simple sin dependencias externas.
func generateID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback con timestamp si rand falla (muy improbable)
return fmt.Sprintf("fallback-%x", b)
}
// Ajustar bits para UUID v4
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
func main() {
// Flags
scriptPath := flag.String("script", "", "Ruta al archivo YAML con el script de navegacion (obligatorio)")
port := flag.Int("port", 9222, "Puerto CDP de Chrome")
launch := flag.Bool("launch", false, "Lanzar Chrome nuevo en vez de conectarse a uno existente")
headless := flag.Bool("headless", false, "Lanzar Chrome en modo headless (requiere --launch)")
chromePath := flag.String("chrome-path", "", "Ruta al ejecutable de Chrome (ej: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe')")
userDataDir := flag.String("user-data-dir", "", "Directorio de perfil de Chrome (path WSL, se convierte a Windows automaticamente)")
keepOpen := flag.Bool("keep-open", false, "No cerrar Chrome al terminar")
abortOnError := flag.Bool("abort-on-error", false, "Abortar el script al primer error en cualquier paso")
flag.Parse()
if *scriptPath == "" {
fmt.Fprintln(os.Stderr, "error: --script es obligatorio")
flag.Usage()
os.Exit(1)
}
if err := run(*scriptPath, *port, *launch, *headless, *abortOnError, *userDataDir, *keepOpen, *chromePath); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run(scriptPath string, port int, launch, headless, abortOnError bool, userDataDir string, keepOpen bool, chromePath string) error {
// 1. Cargar y validar el script YAML
script, err := LoadScript(scriptPath)
if err != nil {
return fmt.Errorf("cargar script: %w", err)
}
fmt.Printf("[script_navegador] script: %q (%d pasos)\n", script.Name, len(script.Steps))
// 2. Inicializar operations.db
appDir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
// Fallback al directorio de trabajo
appDir, _ = os.Getwd()
}
// Si estamos corriendo con `go run .`, os.Args[0] es un tmp, usar cwd
if cwd, e := os.Getwd(); e == nil {
if _, e2 := os.Stat(filepath.Join(cwd, "app.md")); e2 == nil {
appDir = cwd
}
}
db, err := initOpsDB(appDir)
if err != nil {
// No es fatal: seguir sin operations.db, solo logear
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo inicializar operations.db: %v\n", err)
}
if db != nil {
defer db.Close()
}
// 3. Lanzar Chrome o conectarse al existente
var pid int
if launch {
// Convertir path WSL a Windows para chrome.exe
// Si empieza con / es un path Linux (WSL), convertir. Si empieza con letra:\ ya es Windows.
winDataDir := userDataDir
if winDataDir != "" && strings.HasPrefix(winDataDir, "/") {
out, err := exec.Command("wslpath", "-w", winDataDir).Output()
if err == nil {
winDataDir = strings.TrimSpace(string(out))
}
}
fmt.Printf("[chrome] lanzando Chrome en puerto %d (headless=%v, user-data-dir=%q)...\n", port, headless, winDataDir)
pid, err = infra.ChromeLaunch(infra.ChromeLaunchOpts{
Port: port,
Headless: headless,
UserDataDir: winDataDir,
ChromePath: chromePath,
})
if err != nil {
return fmt.Errorf("lanzar Chrome: %w", err)
}
fmt.Printf("[chrome] Chrome lanzado (pid=%d)\n", pid)
} else {
fmt.Printf("[chrome] conectando a Chrome en localhost:%d...\n", port)
}
// 4. Conectar CDP (con mirrored networking, localhost es compartido WSL<->Windows)
fmt.Printf("[cdp] conectando a localhost:%d...\n", port)
conn, err := infra.CdpConnect(port)
if err != nil {
// Si lanzamos Chrome, matar el proceso antes de salir
if pid > 0 {
_ = infra.CdpClose(nil, pid)
}
return fmt.Errorf("conectar CDP en localhost:%d: %w", port, err)
}
fmt.Printf("[cdp] conexion establecida\n")
// Asegurar cierre al salir (respetar --keep-open)
defer func() {
if keepOpen {
fmt.Printf("[cdp] cerrando conexion CDP (Chrome sigue abierto, pid=%d, puerto=%d)\n", pid, port)
// Solo cerrar la conexion WebSocket, no matar Chrome
if err := infra.CdpClose(conn, 0); err != nil {
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar conexion: %v\n", err)
}
} else {
fmt.Printf("[cdp] cerrando conexion y limpiando recursos...\n")
if err := infra.CdpClose(conn, pid); err != nil {
fmt.Fprintf(os.Stderr, "[cdp] aviso al cerrar: %v\n", err)
}
}
}()
// 5. Registrar entities y relations en operations.db
var relationID string
if db != nil {
_, _, _, err := EnsureEntities(db, port, chromePath, userDataDir, script.Name, scriptPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear entities: %v\n", err)
} else {
fmt.Printf("[ops] entities registradas\n")
}
relationID, err = EnsureRelations(db, "chrome_instance", "cdp_session", fmt.Sprintf("script_%s", script.Name))
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudieron crear relations: %v\n", err)
} else {
fmt.Printf("[ops] relations registradas\n")
}
}
// 6. Ejecutar el script
runner := NewRunner(conn, RunnerOpts{AbortOnError: abortOnError})
startedAt := time.Now()
fmt.Printf("[run] iniciando ejecucion: %s\n", startedAt.Format(time.RFC3339))
results, runErr := runner.Run(script)
endedAt := time.Now()
// 7. Imprimir resumen de pasos
printSummary(script, results, runErr, startedAt, endedAt)
// 8. Registrar execution y actualizar relation en operations.db
if db != nil {
execID, err := RecordRun(db, script, relationID, results, runErr, startedAt, endedAt)
if err != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar ejecucion: %v\n", err)
} else {
fmt.Printf("[ops] ejecucion registrada en operations.db (id=%s)\n", execID[:8])
}
// Registrar cada paso como log
for _, r := range results {
if logErr := LogStep(db, execID, r); logErr != nil {
fmt.Fprintf(os.Stderr, "[ops] aviso: no se pudo registrar log step[%d]: %v\n", r.Index, logErr)
}
}
// Actualizar relation status
if relationID != "" {
UpdateRelationAfterRun(db, relationID, runErr)
}
}
if runErr != nil {
return runErr
}
return nil
}
// printSummary imprime un resumen legible de la ejecucion.
func printSummary(script *Script, results []StepResult, runErr error, startedAt, endedAt time.Time) {
duration := endedAt.Sub(startedAt)
success := 0
for _, r := range results {
if r.Err == nil {
success++
}
}
fmt.Printf("\n--- Resumen: %q ---\n", script.Name)
fmt.Printf("Duracion: %v\n", duration.Round(time.Millisecond))
fmt.Printf("Pasos: %d/%d exitosos\n", success, len(results))
fmt.Println()
for _, r := range results {
status := "ok"
detail := ""
if r.Err != nil {
status = "ERROR"
detail = fmt.Sprintf(" -> %v", r.Err)
} else if r.Output != "" {
detail = fmt.Sprintf(" -> %q", r.Output)
}
fmt.Printf(" [%d] %-12s %s (%dms)%s\n",
r.Index, r.Action, status, r.Elapsed.Milliseconds(), detail)
}
if runErr != nil {
fmt.Printf("\nAbortado: %v\n", runErr)
} else {
fmt.Printf("\nScript completado.\n")
}
}
+333
View File
@@ -0,0 +1,333 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
fn_operations "fn-registry/fn_operations"
)
const opsDBName = "operations.db"
// initOpsDB inicializa o abre operations.db en el directorio de la app.
func initOpsDB(appDir string) (*fn_operations.DB, error) {
dbPath := filepath.Join(appDir, opsDBName)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if err := bootstrapOpsDB(appDir, dbPath); err != nil {
return nil, fmt.Errorf("inicializar operations.db: %w", err)
}
}
db, err := fn_operations.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("abrir operations.db: %w", err)
}
return db, nil
}
// bootstrapOpsDB intenta crear operations.db usando el CLI fn o directamente.
func bootstrapOpsDB(appDir, dbPath string) error {
registryRoot := os.Getenv("FN_REGISTRY_ROOT")
if registryRoot == "" {
registryRoot = filepath.Join(appDir, "..", "..")
}
fnBin := filepath.Join(registryRoot, "fn")
if _, err := os.Stat(fnBin); err == nil {
cmd := exec.Command(fnBin, "ops", "init", appDir)
cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+registryRoot)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("fn ops init: %w\n%s", err, out)
}
return nil
}
db, err := fn_operations.Open(dbPath)
if err != nil {
return fmt.Errorf("crear operations.db directamente: %w", err)
}
return db.Close()
}
// --- Entities ---
// EnsureEntities crea o actualiza las entities del pipeline de navegacion.
// Entities:
// - chrome_instance: la instancia de Chrome con CDP
// - cdp_session: la sesion CDP activa
// - script_file: el archivo YAML del script
func EnsureEntities(db *fn_operations.DB, port int, chromePath, userDataDir, scriptName, scriptPath string) (chromeID, cdpID, scriptID string, err error) {
now := time.Now()
chromeID = "chrome_instance"
cdpID = "cdp_session"
scriptID = fmt.Sprintf("script_%s", scriptName)
// Chrome instance
existing, _ := db.GetEntity(chromeID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: chromeID,
Name: "Chrome Windows",
TypeRef: "chrome_instance",
Status: fn_operations.StatusActive,
Description: "Instancia de Chrome con remote debugging habilitado",
Domain: "infra",
Tags: []string{"chrome", "cdp", "windows"},
Source: "script_navegador",
Metadata: map[string]any{
"port": port,
"chrome_path": chromePath,
"user_data_dir": userDataDir,
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity chrome_instance: %w", err)
}
} else if existing.Status != fn_operations.StatusActive {
existing.Status = fn_operations.StatusActive
existing.UpdatedAt = now
db.UpdateEntity(existing)
}
// CDP session
existing, _ = db.GetEntity(cdpID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: cdpID,
Name: "CDP Session",
TypeRef: "cdp_session",
Status: fn_operations.StatusActive,
Description: "Sesion CDP WebSocket activa contra Chrome",
Domain: "infra",
Tags: []string{"cdp", "websocket"},
Source: "script_navegador",
Metadata: map[string]any{
"port": port,
"protocol": "CDP 1.3",
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity cdp_session: %w", err)
}
} else if existing.Status != fn_operations.StatusActive {
existing.Status = fn_operations.StatusActive
existing.UpdatedAt = now
db.UpdateEntity(existing)
}
// Script
existing, _ = db.GetEntity(scriptID)
if existing == nil {
err = db.InsertEntity(&fn_operations.Entity{
ID: scriptID,
Name: scriptName,
TypeRef: "nav_script",
Status: fn_operations.StatusActive,
Description: fmt.Sprintf("Script de navegacion: %s", scriptName),
Domain: "automation",
Tags: []string{"script", "yaml", "navegacion"},
Source: scriptPath,
Metadata: map[string]any{
"script_name": scriptName,
"file_path": scriptPath,
},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", "", "", fmt.Errorf("insertar entity script: %w", err)
}
}
return chromeID, cdpID, scriptID, nil
}
// --- Relations ---
// EnsureRelations crea las relaciones entre entities si no existen.
// Relations:
// - chrome_to_cdp: Chrome -> CDP Session (via chrome_launch + cdp_connect)
// - cdp_to_script: CDP Session -> Script (via runner)
func EnsureRelations(db *fn_operations.DB, chromeID, cdpID, scriptID string) (string, error) {
now := time.Now()
// chrome -> cdp
chromeToCDP := "chrome_to_cdp"
existing, _ := db.GetRelation(chromeToCDP)
if existing == nil {
err := db.InsertRelation(&fn_operations.Relation{
ID: chromeToCDP,
Name: "chrome_to_cdp",
FromEntity: chromeID,
ToEntity: cdpID,
Via: "cdp_connect_go_infra",
Description: "Chrome expone CDP, la app se conecta via WebSocket",
Purity: "impure",
Direction: fn_operations.DirUnidirectional,
Status: fn_operations.RelImplemented,
Tags: []string{"cdp", "websocket"},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", fmt.Errorf("insertar relation chrome_to_cdp: %w", err)
}
}
// cdp -> script execution
cdpToScript := fmt.Sprintf("cdp_to_%s", scriptID)
existing, _ = db.GetRelation(cdpToScript)
if existing == nil {
startedAt := now
err := db.InsertRelation(&fn_operations.Relation{
ID: cdpToScript,
Name: cdpToScript,
FromEntity: cdpID,
ToEntity: scriptID,
Via: "script_navegador_runner",
Description: fmt.Sprintf("CDP ejecuta pasos del script %s", scriptID),
Purity: "impure",
Direction: fn_operations.DirUnidirectional,
Status: fn_operations.RelRunning,
StartedAt: &startedAt,
Tags: []string{"automation", "pipeline"},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return "", fmt.Errorf("insertar relation cdp_to_script: %w", err)
}
} else {
existing.Status = fn_operations.RelRunning
existing.UpdatedAt = now
db.UpdateRelation(existing)
}
return cdpToScript, nil
}
// UpdateRelationAfterRun actualiza el status de la relation segun el resultado.
func UpdateRelationAfterRun(db *fn_operations.DB, relationID string, runErr error) {
rel, err := db.GetRelation(relationID)
if err != nil || rel == nil {
return
}
if runErr != nil {
rel.Status = fn_operations.RelImplemented
} else {
rel.Status = fn_operations.RelTested
}
now := time.Now()
rel.EndedAt = &now
rel.UpdatedAt = now
db.UpdateRelation(rel)
}
// --- Executions ---
// RecordRun registra una ejecucion completa del script en operations.db.
func RecordRun(db *fn_operations.DB, script *Script, relationID string, results []StepResult, runErr error, startedAt, endedAt time.Time) (string, error) {
totalSteps := int64(len(results))
successSteps := int64(0)
for _, r := range results {
if r.Err == nil {
successSteps++
}
}
status := fn_operations.ExecSuccess
errMsg := ""
if runErr != nil {
status = fn_operations.ExecFailure
errMsg = runErr.Error()
} else if successSteps < totalSteps {
status = fn_operations.ExecPartial
}
durationMs := endedAt.Sub(startedAt).Milliseconds()
stepSummary := make([]map[string]any, 0, len(results))
for _, r := range results {
entry := map[string]any{
"index": r.Index,
"action": r.Action,
"elapsed_ms": r.Elapsed.Milliseconds(),
"ok": r.Err == nil,
}
if r.Output != "" {
entry["output"] = r.Output
}
if r.Err != nil {
entry["error"] = r.Err.Error()
}
stepSummary = append(stepSummary, entry)
}
execID := generateID()
execution := &fn_operations.Execution{
ID: execID,
PipelineID: "script_navegador",
RelationID: relationID,
Status: status,
StartedAt: startedAt,
EndedAt: &endedAt,
DurationMs: &durationMs,
RecordsIn: &totalSteps,
RecordsOut: &successSteps,
Error: errMsg,
Metrics: map[string]any{
"script_name": script.Name,
"total_steps": totalSteps,
"success_steps": successSteps,
"steps": stepSummary,
},
}
if err := db.InsertExecution(execution); err != nil {
return "", fmt.Errorf("insertar execution: %w", err)
}
return execID, nil
}
// --- Logs ---
// LogStep registra un paso individual como log en operations.db.
func LogStep(db *fn_operations.DB, execID string, res StepResult) error {
level := fn_operations.LogInfo
msg := fmt.Sprintf("step[%d] %s: ok", res.Index, res.Action)
if res.Err != nil {
level = fn_operations.LogError
msg = fmt.Sprintf("step[%d] %s: %v", res.Index, res.Action, res.Err)
}
meta := map[string]any{
"action": res.Action,
"elapsed_ms": res.Elapsed.Milliseconds(),
}
if res.Output != "" {
meta["output"] = res.Output
}
log := &fn_operations.Log{
ID: generateID(),
Level: level,
Source: "script_navegador",
ExecutionID: execID,
Message: msg,
Metadata: meta,
}
return db.InsertLog(log)
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"fmt"
"time"
"fn-registry/functions/infra"
)
// StepResult es el resultado de ejecutar un paso.
type StepResult struct {
Index int
Action string
Output string // resultado de evaluate/get_html, path de screenshot, etc.
Err error
Elapsed time.Duration
}
// RunnerOpts configura la ejecucion del runner.
type RunnerOpts struct {
AbortOnError bool
}
// Runner ejecuta los pasos de un Script sobre una conexion CDP activa.
type Runner struct {
conn *infra.CDPConn
opts RunnerOpts
}
// NewRunner crea un Runner con la conexion CDP dada.
func NewRunner(conn *infra.CDPConn, opts RunnerOpts) *Runner {
return &Runner{conn: conn, opts: opts}
}
// Run ejecuta todos los pasos del script y retorna los resultados de cada paso.
// Siempre retorna todos los resultados procesados hasta el momento, incluso si aborta.
func (r *Runner) Run(script *Script) ([]StepResult, error) {
results := make([]StepResult, 0, len(script.Steps))
for i, step := range script.Steps {
start := time.Now()
output, err := r.runStep(step)
elapsed := time.Since(start)
res := StepResult{
Index: i,
Action: step.Action,
Output: output,
Err: err,
Elapsed: elapsed,
}
results = append(results, res)
if err != nil {
if step.ContinueOnError {
// Continuar con el siguiente paso aunque este fallo
continue
}
if r.opts.AbortOnError {
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
}
// Por defecto: abortar si el paso fallo y no tiene continue_on_error
return results, fmt.Errorf("step[%d] %s: %w", i, step.Action, err)
}
}
return results, nil
}
// runStep ejecuta un paso individual y retorna su output y error.
func (r *Runner) runStep(step Step) (string, error) {
switch step.Action {
case "navigate":
if err := infra.CdpNavigate(r.conn, step.URL); err != nil {
return "", err
}
// Esperar a que la página cargue completamente
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}
return "", infra.CdpWaitLoad(r.conn, timeout)
case "wait_load":
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}
return "", infra.CdpWaitLoad(r.conn, timeout)
case "wait":
timeout := time.Duration(step.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
return "", infra.CdpWaitElement(r.conn, step.Selector, timeout)
case "click":
return "", infra.CdpClick(r.conn, step.Selector)
case "type":
// Hacer click primero para enfocar el elemento
if err := infra.CdpClick(r.conn, step.Selector); err != nil {
return "", fmt.Errorf("enfocar elemento para type: %w", err)
}
return "", infra.CdpTypeText(r.conn, step.Text)
case "screenshot":
opts := infra.CdpScreenshotOpts{
FullPage: step.FullPage,
Format: "png",
}
if err := infra.CdpScreenshot(r.conn, step.Path, opts); err != nil {
return "", err
}
return step.Path, nil
case "evaluate":
result, err := infra.CdpEvaluate(r.conn, step.Expr)
if err != nil {
return "", err
}
return result, nil
case "get_html":
html, err := infra.CdpGetHTML(r.conn)
if err != nil {
return "", err
}
// Truncar para el log (el HTML puede ser muy largo)
if len(html) > 200 {
return html[:200] + "...", nil
}
return html, nil
case "sleep":
time.Sleep(time.Duration(step.Ms) * time.Millisecond)
return fmt.Sprintf("slept %dms", step.Ms), nil
default:
return "", fmt.Errorf("accion desconocida: %q", step.Action)
}
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Script representa un archivo YAML de pasos de navegacion.
type Script struct {
Name string `yaml:"name"`
Steps []Step `yaml:"steps"`
}
// Step es un paso individual dentro del script.
type Step struct {
// Comun a todos los pasos
Action string `yaml:"action"`
ContinueOnError bool `yaml:"continue_on_error"`
// navigate
URL string `yaml:"url"`
// wait
Selector string `yaml:"selector"`
TimeoutMs int `yaml:"timeout_ms"`
// type
Text string `yaml:"text"`
// screenshot
Path string `yaml:"path"`
FullPage bool `yaml:"full_page"`
// evaluate
Expr string `yaml:"expr"`
// sleep
Ms int `yaml:"ms"`
}
// Validate comprueba que el script tiene los campos minimos correctos.
func (s *Script) Validate() error {
if s.Name == "" {
return fmt.Errorf("script: campo 'name' obligatorio")
}
if len(s.Steps) == 0 {
return fmt.Errorf("script %q: sin pasos definidos", s.Name)
}
for i, step := range s.Steps {
if err := step.Validate(i); err != nil {
return err
}
}
return nil
}
// Validate comprueba que el paso tiene los campos requeridos segun su action.
func (s *Step) Validate(idx int) error {
prefix := fmt.Sprintf("step[%d] action=%q", idx, s.Action)
switch s.Action {
case "navigate":
if s.URL == "" {
return fmt.Errorf("%s: campo 'url' obligatorio", prefix)
}
case "wait":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
case "click":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
case "type":
if s.Selector == "" {
return fmt.Errorf("%s: campo 'selector' obligatorio", prefix)
}
if s.Text == "" {
return fmt.Errorf("%s: campo 'text' obligatorio", prefix)
}
case "screenshot":
if s.Path == "" {
return fmt.Errorf("%s: campo 'path' obligatorio", prefix)
}
case "evaluate":
if s.Expr == "" {
return fmt.Errorf("%s: campo 'expr' obligatorio", prefix)
}
case "get_html":
// sin parametros requeridos
case "wait_load":
// sin parametros requeridos (timeout_ms opcional)
case "sleep":
if s.Ms <= 0 {
return fmt.Errorf("%s: campo 'ms' debe ser mayor que 0", prefix)
}
default:
return fmt.Errorf("%s: accion desconocida (navigate|wait|wait_load|click|type|screenshot|evaluate|get_html|sleep)", prefix)
}
return nil
}
// LoadScript lee y parsea un archivo YAML de script de navegador.
func LoadScript(path string) (*Script, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("leer script %q: %w", path, err)
}
var s Script
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parsear script %q: %w", path, err)
}
if err := s.Validate(); err != nil {
return nil, err
}
return &s, nil
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"io"
"net"
"os"
"strings"
"time"
)
// getWindowsHostIP obtiene la IP del host Windows desde WSL2.
// Lee /etc/resolv.conf que WSL2 configura con la IP del host.
func getWindowsHostIP() string {
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver ") {
ip := strings.TrimPrefix(line, "nameserver ")
ip = strings.TrimSpace(ip)
if ip != "" {
return ip
}
}
}
return ""
}
// getWindowsGatewayIP obtiene la IP del gateway (host Windows) desde la tabla de rutas.
func getWindowsGatewayIP() string {
data, err := os.ReadFile("/proc/net/route")
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) >= 3 && fields[1] == "00000000" { // default route
hexIP := fields[2]
if len(hexIP) == 8 {
// /proc/net/route stores IPs as little-endian 32-bit hex
// "011017AC" -> bytes [01,10,17,AC] -> IP 172.23.16.1 (reversed)
var a, b, c, d uint8
fmt.Sscanf(hexIP[0:2], "%02x", &a)
fmt.Sscanf(hexIP[2:4], "%02x", &b)
fmt.Sscanf(hexIP[4:6], "%02x", &c)
fmt.Sscanf(hexIP[6:8], "%02x", &d)
return fmt.Sprintf("%d.%d.%d.%d", d, c, b, a)
}
}
}
return ""
}
// waitForCDP espera a que el puerto CDP esté accesible desde WSL.
func waitForCDP(host string, port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("%s:%d", host, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("CDP %s no disponible despues de %s", addr, timeout)
}
// startCDPProxy levanta un proxy TCP local que reenvía conexiones al host Windows.
// Chrome CDP solo acepta conexiones desde localhost, así que el proxy en WSL
// conecta al host Windows vía portproxy/netsh y expone el puerto localmente.
// Retorna el puerto local del proxy y una función para cerrarlo.
func startCDPProxy(windowsHost string, remotePort, localPort int) (net.Listener, error) {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
if err != nil {
return nil, fmt.Errorf("proxy listen: %w", err)
}
go func() {
for {
client, err := ln.Accept()
if err != nil {
return // listener cerrado
}
go proxyConn(client, windowsHost, remotePort)
}
}()
return ln, nil
}
func proxyConn(client net.Conn, host string, port int) {
defer client.Close()
remote, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
if err != nil {
return
}
defer remote.Close()
go io.Copy(remote, client)
io.Copy(client, remote)
}
+22
View File
@@ -105,6 +105,18 @@ func cmdIndex() {
// Flush WAL to main db file so external readers (e.g. Metabase) see changes.
db.WalCheckpoint()
// Sync registry.db to Metabase mount directory if it exists.
metabaseCopy := filepath.Join(r, ".metabase-registry", "registry.db")
if _, err := os.Stat(filepath.Dir(metabaseCopy)); err == nil {
src := filepath.Join(r, dbName)
data, err := os.ReadFile(src)
if err == nil {
if err := os.WriteFile(metabaseCopy, data, 0666); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not sync to metabase: %v\n", err)
}
}
}
fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps)
for _, e := range result.ValidationErrors {
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
@@ -341,6 +353,11 @@ func printFunction(f *registry.Function) {
if f.Code != "" {
fmt.Printf("\nCode:\n%s\n", f.Code)
}
if f.SourceRepo != "" {
fmt.Printf("Source repo: %s\n", f.SourceRepo)
fmt.Printf("Source license: %s\n", f.SourceLicense)
fmt.Printf("Source file: %s\n", f.SourceFile)
}
if f.Kind == registry.KindComponent {
fmt.Printf("Framework: %s\n", f.Framework)
if f.HasState != nil {
@@ -365,6 +382,11 @@ func printType(t *registry.Type) {
if len(t.UsesTypes) > 0 {
fmt.Printf("Uses types: %s\n", strings.Join(t.UsesTypes, ", "))
}
if t.SourceRepo != "" {
fmt.Printf("Source repo: %s\n", t.SourceRepo)
fmt.Printf("Source license: %s\n", t.SourceLicense)
fmt.Printf("Source file: %s\n", t.SourceFile)
}
if t.Definition != "" {
fmt.Printf("\nDefinition:\n%s\n", t.Definition)
}
+4
View File
@@ -18,6 +18,10 @@ tested: false
tests: []
test_file_path: ""
file_path: "functions/core/filter_slice.go"
# Source attribution (solo para funciones extraidas de repos externos)
# source_repo: "https://github.com/user/project"
# source_license: "MIT"
# source_file: "pkg/utils.go"
---
## Ejemplo
+68
View File
@@ -0,0 +1,68 @@
package core
import (
"database/sql"
"fmt"
)
// DetectCycle checks if adding a directed edge (from -> to) would create a cycle
// in a directed graph stored in a SQLite table.
// It performs BFS from toNode following edges where the filter column is non-empty.
// If it reaches fromNode, a cycle exists.
//
// Parameters:
// - conn: open *sql.DB connection
// - table: table name containing the edges (e.g. "relations")
// - fromCol: column name for edge source (e.g. "from_entity")
// - toCol: column name for edge destination (e.g. "to_entity")
// - filterCol: column name that must be non-empty for causal edges (e.g. "via"); pass "" to consider all edges
// - fromNode: source node of the proposed new edge
// - toNode: destination node of the proposed new edge
func DetectCycle(conn *sql.DB, table, fromCol, toCol, filterCol, fromNode, toNode string) error {
if fromNode == "" || toNode == "" {
return nil
}
var query string
if filterCol != "" {
query = fmt.Sprintf("SELECT %s FROM %s WHERE %s = ? AND %s != ''", toCol, table, fromCol, filterCol)
} else {
query = fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", toCol, table, fromCol)
}
visited := map[string]bool{}
queue := []string{toNode}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
if visited[current] {
continue
}
visited[current] = true
if current == fromNode {
return fmt.Errorf("cycle detected: adding edge %s -> %s would create a cycle", fromNode, toNode)
}
rows, err := conn.Query(query, current)
if err != nil {
return fmt.Errorf("querying %s for cycle detection: %w", table, err)
}
for rows.Next() {
var next string
if err := rows.Scan(&next); err != nil {
rows.Close()
return err
}
if !visited[next] {
queue = append(queue, next)
}
}
rows.Close()
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
---
name: detect_cycle
kind: function
lang: go
domain: core
version: "1.0.0"
purity: impure
signature: "func DetectCycle(conn *sql.DB, table, fromCol, toCol, filterCol, fromNode, toNode string) error"
description: "Detecta ciclos en un grafo dirigido almacenado en SQLite usando BFS antes de insertar una arista."
tags: [graph, cycle, bfs, sqlite, validation]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["database/sql"]
tested: false
tests: []
test_file_path: ""
file_path: "functions/core/detect_cycle.go"
---
## Ejemplo
```go
// Verificar si agregar A -> B crearia un ciclo en la tabla "relations"
err := DetectCycle(db, "relations", "from_entity", "to_entity", "via", "A", "B")
if err != nil {
// ciclo detectado
}
// Sin filtro — considerar todas las aristas
err = DetectCycle(db, "edges", "source", "target", "", "X", "Y")
```
## Notas
Usa BFS desde toNode siguiendo aristas existentes. Si alcanza fromNode, la nueva arista crearia un ciclo. El parametro filterCol permite ignorar aristas semanticas (no causales) — pasar "" para considerar todas.
+7
View File
@@ -0,0 +1,7 @@
package core
// GenerateID builds a canonical ID from name, lang, and domain.
// Format: {name}_{lang}_{domain}
func GenerateID(name, lang, domain string) string {
return name + "_" + lang + "_" + domain
}
+32
View File
@@ -0,0 +1,32 @@
---
name: generate_id
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func GenerateID(name, lang, domain string) string"
description: "Genera un ID canonico determinista a partir de nombre, lenguaje y dominio."
tags: [id, naming, deterministic]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "functions/core/generate_id.go"
---
## Ejemplo
```go
id := GenerateID("filter_slice", "go", "core")
// id = "filter_slice_go_core"
```
## Notas
Funcion pura sin dependencias. Util para cualquier sistema que necesite IDs compuestos deterministas a partir de componentes con nombre.
+58
View File
@@ -0,0 +1,58 @@
package core
import (
"fmt"
"regexp"
"strings"
)
var rewriteFieldPattern = regexp.MustCompile(`\b([a-zA-Z_][a-zA-Z0-9_]*)\b`)
var rewriteSQLKeywords = map[string]bool{
"AND": true, "OR": true, "NOT": true, "IS": true, "NULL": true,
"IN": true, "BETWEEN": true, "LIKE": true, "GLOB": true,
"TRUE": true, "FALSE": true, "CASE": true, "WHEN": true,
"THEN": true, "ELSE": true, "END": true, "SELECT": true,
"FROM": true, "WHERE": true, "AS": true, "CAST": true,
}
var rewriteSQLFunctions = map[string]bool{
"json_extract": true, "datetime": true, "now": true,
"abs": true, "avg": true, "count": true, "max": true, "min": true,
"sum": true, "total": true, "length": true, "typeof": true,
"coalesce": true, "ifnull": true, "nullif": true,
"upper": true, "lower": true, "trim": true, "replace": true,
"substr": true, "instr": true, "round": true,
}
// RewriteRule transforms a rule expression so that bare field names become
// json_extract calls on a given JSON column.
// For example, with column "metadata":
//
// "price > 100 AND status IS NOT NULL"
//
// becomes:
//
// "json_extract(metadata, '$.price') > 100 AND json_extract(metadata, '$.status') IS NOT NULL"
//
// If the rule already contains json_extract, it is returned as-is.
// SQL keywords and common SQL functions are preserved.
func RewriteRule(rule, jsonColumn string) string {
if strings.Contains(rule, "json_extract") {
return rule
}
return rewriteFieldPattern.ReplaceAllStringFunc(rule, func(match string) string {
upper := strings.ToUpper(match)
if rewriteSQLKeywords[upper] {
return match
}
if rewriteSQLFunctions[strings.ToLower(match)] {
return match
}
if match[0] >= '0' && match[0] <= '9' {
return match
}
return fmt.Sprintf("json_extract(%s, '$.%s')", jsonColumn, match)
})
}
+36
View File
@@ -0,0 +1,36 @@
---
name: rewrite_rule
kind: function
lang: go
domain: core
version: "1.0.0"
purity: pure
signature: "func RewriteRule(rule, jsonColumn string) string"
description: "Reescribe campos bare en una expresion SQL a llamadas json_extract sobre una columna JSON de SQLite."
tags: [sql, json, sqlite, rewrite, assertion]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["regexp"]
tested: false
tests: []
test_file_path: ""
file_path: "functions/core/rewrite_rule.go"
---
## Ejemplo
```go
out := RewriteRule("price > 100 AND status IS NOT NULL", "metadata")
// out = "json_extract(metadata, '$.price') > 100 AND json_extract(metadata, '$.status') IS NOT NULL"
// Si ya tiene json_extract, no modifica nada
out = RewriteRule("json_extract(data, '$.x') > 0", "data")
// out = "json_extract(data, '$.x') > 0"
```
## Notas
Funcion pura. Preserva keywords SQL y funciones SQLite conocidas. Util para construir queries dinamicas sobre columnas JSON en SQLite sin que el usuario tenga que escribir json_extract manualmente.
+22 -10
View File
@@ -20,8 +20,11 @@ type cdpTarget struct {
// cdpGetPageWSURL obtiene el webSocketDebuggerUrl de la primera tab de tipo "page"
// via el endpoint /json. Si no hay ninguna, crea una nueva con /json/new.
func cdpGetPageWSURL(port int) (string, error) {
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/json", port))
func cdpGetPageWSURL(host string, port int) (string, error) {
if host == "" {
host = "localhost"
}
resp, err := http.Get(fmt.Sprintf("http://%s:%d/json", host, port))
if err != nil {
return "", fmt.Errorf("cdp targets: %w", err)
}
@@ -40,7 +43,7 @@ func cdpGetPageWSURL(port int) (string, error) {
}
// No hay tabs — crear una nueva via /json/new
newResp, err := http.Get(fmt.Sprintf("http://localhost:%d/json/new", port))
newResp, err := http.Get(fmt.Sprintf("http://%s:%d/json/new", host, port))
if err != nil {
return "", fmt.Errorf("cdp new tab: %w", err)
}
@@ -61,7 +64,16 @@ func cdpGetPageWSURL(port int) (string, error) {
// Si no hay tabs disponibles, crea una nueva via /json/new.
// Realiza el handshake WebSocket RFC 6455 sobre TCP puro (sin dependencias externas).
func CdpConnect(port int) (*CDPConn, error) {
wsURL, err := cdpGetPageWSURL(port)
return CdpConnectHost("localhost", port)
}
// CdpConnectHost es como CdpConnect pero permite especificar el host.
// Util para WSL2 donde Chrome Windows escucha en una IP distinta a localhost.
func CdpConnectHost(host string, port int) (*CDPConn, error) {
if host == "" {
host = "localhost"
}
wsURL, err := cdpGetPageWSURL(host, port)
if err != nil {
return nil, fmt.Errorf("cdp connect: %w", err)
}
@@ -72,20 +84,20 @@ func CdpConnect(port int) (*CDPConn, error) {
return nil, fmt.Errorf("cdp connect: parsear ws url %q: %w", wsURL, err)
}
host := u.Host
if !strings.Contains(host, ":") {
host = host + ":80"
wsHost := u.Host
if !strings.Contains(wsHost, ":") {
wsHost = wsHost + ":80"
}
// Abrir conexion TCP
tcpConn, err := net.Dial("tcp", host)
tcpConn, err := net.Dial("tcp", wsHost)
if err != nil {
return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", host, err)
return nil, fmt.Errorf("cdp connect: tcp dial %s: %w", wsHost, err)
}
// Realizar handshake WebSocket
path := u.RequestURI()
reader, err := wsHandshake(tcpConn, host, path)
reader, err := wsHandshake(tcpConn, wsHost, path)
if err != nil {
tcpConn.Close()
return nil, fmt.Errorf("cdp connect: ws handshake: %w", err)
+35
View File
@@ -0,0 +1,35 @@
package infra
import (
"fmt"
"time"
)
// CdpWaitLoad espera a que la página actual termine de cargar completamente.
// Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta
// que sea "complete", o hasta que se agote el timeout.
// Retorna error si el timeout se agota o si CdpEvaluate falla (conexion rota).
func CdpWaitLoad(c *CDPConn, timeout time.Duration) error {
if c == nil {
return fmt.Errorf("cdp wait load: conexion nula")
}
if timeout <= 0 {
timeout = 30 * time.Second
}
deadline := time.Now().Add(timeout)
interval := 200 * time.Millisecond
for time.Now().Before(deadline) {
result, err := CdpEvaluate(c, "document.readyState")
if err != nil {
return fmt.Errorf("cdp wait load: error evaluando readyState: %w", err)
}
if result == "complete" {
return nil
}
time.Sleep(interval)
}
return fmt.Errorf("cdp wait load: pagina no cargo despues de %s", timeout)
}
+41
View File
@@ -0,0 +1,41 @@
---
name: cdp_wait_load
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func CdpWaitLoad(c *CDPConn, timeout time.Duration) error"
description: "Espera a que la pagina actual termine de cargar completamente. Hace polling de document.readyState via Runtime.evaluate cada 200ms hasta que sea \"complete\", o hasta que se agote el timeout. Retorna error inmediato si CdpEvaluate falla (la conexion puede estar rota)."
tags: [chrome, cdp, browser, automation, wait, polling, devtools, readystate, load]
uses_functions: [cdp_evaluate_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, time]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/cdp_wait_load.go"
---
## Ejemplo
```go
conn, _ := CdpConnect(9222)
CdpNavigate(conn, "https://example.com")
// Esperar hasta 30 segundos a que la pagina cargue por completo
if err := CdpWaitLoad(conn, 30*time.Second); err != nil {
log.Fatal("Timeout esperando carga:", err)
}
html, _ := CdpGetHTML(conn)
```
## Notas
A diferencia de `CdpWaitElement`, que ignora errores de `CdpEvaluate` durante el polling (la pagina puede aun no estar lista), `CdpWaitLoad` retorna el error inmediatamente porque un fallo en `document.readyState` indica una conexion rota, no una condicion transitoria.
Si `timeout <= 0` usa 30s por defecto (mas largo que `CdpWaitElement` porque la carga completa de red puede tardar mas que la aparicion de un elemento DOM).
+25 -6
View File
@@ -16,6 +16,8 @@ type ChromeLaunchOpts struct {
UserDataDir string
// Headless activa el modo headless (--headless=new). Por defecto false.
Headless bool
// ChromePath es la ruta al ejecutable de Chrome. Si esta vacio, se busca automaticamente.
ChromePath string
// ExtraArgs permite pasar flags adicionales a Chrome.
ExtraArgs []string
}
@@ -45,9 +47,13 @@ func findChrome() (string, error) {
}
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
func waitCDPReady(port int, timeout time.Duration) error {
// host puede estar vacio (usa "localhost").
func waitCDPReady(host string, port int, timeout time.Duration) error {
if host == "" {
host = "localhost"
}
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("localhost:%d", port)
addr := fmt.Sprintf("%s:%d", host, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
@@ -56,7 +62,7 @@ func waitCDPReady(port int, timeout time.Duration) error {
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("chrome: puerto CDP %d no disponible despues de %s", port, timeout)
return fmt.Errorf("chrome: puerto CDP %s:%d no disponible despues de %s", host, port, timeout)
}
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
@@ -70,10 +76,14 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
opts.UserDataDir = "/tmp/chrome-cdp-profile"
}
chromePath, err := findChrome()
chromePath := opts.ChromePath
if chromePath == "" {
var err error
chromePath, err = findChrome()
if err != nil {
return 0, err
}
}
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", opts.Port),
@@ -111,11 +121,20 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
pid := cmd.Process.Pid
// Esperar a que el puerto CDP este listo
if err := waitCDPReady(opts.Port, 15*time.Second); err != nil {
// Matar proceso si no arranco correctamente
// Si Chrome escucha en 0.0.0.0 (ej: WSL2 -> Windows), el caller se encarga del wait
skipWait := false
for _, a := range opts.ExtraArgs {
if a == "--remote-debugging-address=0.0.0.0" {
skipWait = true
break
}
}
if !skipWait {
if err := waitCDPReady("localhost", opts.Port, 15*time.Second); err != nil {
cmd.Process.Kill()
return 0, err
}
}
return pid, nil
}
+41
View File
@@ -0,0 +1,41 @@
package infra
import (
"fmt"
)
// DeployApp orquesta el deploy completo de una app Go en Docker.
// Pasos: genera Dockerfile → lo escribe → build image → run container (detach, port mapping).
// Retorna el container ID del contenedor lanzado.
func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error) {
// 1. Generar Dockerfile (puro)
content := GenerateDockerfile(imageName, port, envVars)
// 2. Escribir Dockerfile a disco
_, err := WriteDockerfile(appDir, content)
if err != nil {
return "", fmt.Errorf("deploy_app: escribir Dockerfile: %w", err)
}
// 3. Build de la imagen Docker
tag := imageName + ":latest"
_, err = DockerBuildImage(appDir, tag, nil)
if err != nil {
return "", fmt.Errorf("deploy_app: build imagen %s: %w", tag, err)
}
// 4. Ejecutar el contenedor en modo detach con port mapping
portMapping := fmt.Sprintf("%d:%d", port, port)
opts := DockerRunOpts{
Name: imageName,
Ports: []string{portMapping},
Env: envVars,
Detach: true,
}
containerID, err := DockerRunContainer(tag, opts)
if err != nil {
return "", fmt.Errorf("deploy_app: run contenedor %s: %w", imageName, err)
}
return containerID, nil
}
+43
View File
@@ -0,0 +1,43 @@
---
name: deploy_app
kind: pipeline
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error)"
description: "Orquesta el deploy completo de una app Go en Docker. Pasos: genera Dockerfile, lo escribe a disco, construye la imagen y lanza el contenedor en modo detach con port mapping. Retorna el container ID."
tags: [docker, deploy, go, pipeline, infra, container]
uses_functions: [generate_dockerfile_go_infra, write_dockerfile_go_infra, docker_build_image_go_infra, docker_run_container_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/deploy_app.go"
---
## Ejemplo
```go
containerID, err := DeployApp(
"/home/user/apps/myapp",
"myapp",
8080,
map[string]string{
"DB_HOST": "postgres",
"PORT": "8080",
},
)
if err != nil {
log.Fatal(err)
}
fmt.Println("Contenedor lanzado:", containerID)
```
## Notas
Pipeline de 4 pasos: generate_dockerfile (pura) → write_dockerfile → docker_build_image → docker_run_container. El nombre del contenedor e imagen coinciden con imageName. El port mapping es simetrico (hostPort == containerPort). Si cualquier paso falla, el pipeline retorna error con contexto del paso fallido. No hace rollback automatico — para limpiar usar stop_app.
+51
View File
@@ -0,0 +1,51 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DockerBuildImage construye una imagen Docker desde un directorio con Dockerfile.
// Retorna el image ID de la imagen construida.
func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error) {
args := []string{"build", "-t", tag}
for k, v := range buildArgs {
args = append(args, "--build-arg", k+"="+v)
}
args = append(args, contextDir)
out, err := exec.Command("docker", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker build %s: %s", tag, strings.TrimSpace(string(out)))
}
// Extraer el image ID de la ultima linea de output
// docker build imprime "Successfully built <id>" o con BuildKit "writing image sha256:<id>"
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.Contains(line, "Successfully built ") {
parts := strings.Fields(line)
return parts[len(parts)-1], nil
}
if strings.Contains(line, "sha256:") {
// BuildKit: extraer sha256:...
idx := strings.Index(line, "sha256:")
id := line[idx:]
if sp := strings.IndexAny(id, " \t,"); sp != -1 {
id = id[:sp]
}
return id, nil
}
}
// Si no encontramos el ID en el output, inspeccionar por tag
inspectOut, err2 := exec.Command("docker", "inspect", "--format", "{{.Id}}", tag).Output()
if err2 != nil {
return tag, nil
}
return strings.TrimSpace(string(inspectOut)), nil
}
+38
View File
@@ -0,0 +1,38 @@
---
name: docker_build_image
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error)"
description: "Construye una imagen Docker desde un directorio con Dockerfile. Soporta build args opcionales. Retorna el image ID de la imagen construida."
tags: [docker, image, build, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["build sin build args retorna image ID", "build con build args incluye --build-arg", "error si contextDir no existe"]
test_file_path: "functions/infra/docker_build_image_test.go"
file_path: "functions/infra/docker_build_image.go"
---
## Ejemplo
```go
imageID, err := DockerBuildImage("./myapp", "myapp:latest", map[string]string{
"VERSION": "1.2.3",
"ENV": "production",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("Built:", imageID)
```
## Notas
Ejecuta `docker build -t tag contextDir --build-arg key=val ...`. Parsea el image ID del output de docker build, compatible con el builder clasico (mensajes "Successfully built") y BuildKit (sha256). Si no puede parsear el ID del output, hace un `docker inspect` por tag como fallback.
@@ -0,0 +1,58 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestDockerBuildImage(t *testing.T) {
t.Run("build sin build args retorna image ID", func(t *testing.T) {
// Crear un Dockerfile temporal minimo
dir := t.TempDir()
dockerfile := filepath.Join(dir, "Dockerfile")
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0644); err != nil {
t.Skip("no se pudo crear Dockerfile temporal:", err)
}
id, err := DockerBuildImage(dir, "test-fn-registry-scratch:latest", nil)
if err != nil {
// Docker puede no estar disponible en CI — skip en vez de fail
t.Skipf("docker build no disponible: %v", err)
}
if id == "" {
t.Error("se esperaba un image ID no vacio")
}
// Limpiar
_ = DockerRemoveImage("test-fn-registry-scratch:latest", true)
})
t.Run("build con build args incluye --build-arg", func(t *testing.T) {
dir := t.TempDir()
dockerfile := filepath.Join(dir, "Dockerfile")
content := "FROM scratch\nARG MY_VERSION\n"
if err := os.WriteFile(dockerfile, []byte(content), 0644); err != nil {
t.Skip("no se pudo crear Dockerfile:", err)
}
id, err := DockerBuildImage(dir, "test-fn-registry-args:latest", map[string]string{
"MY_VERSION": "42",
})
if err != nil {
t.Skipf("docker build no disponible: %v", err)
}
if id == "" {
t.Error("se esperaba un image ID no vacio")
}
_ = DockerRemoveImage("test-fn-registry-args:latest", true)
})
t.Run("error si contextDir no existe", func(t *testing.T) {
_, err := DockerBuildImage("/ruta/que/no/existe/nunca", "test:latest", nil)
if err == nil {
t.Error("se esperaba error para directorio inexistente")
}
})
}
+22
View File
@@ -0,0 +1,22 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DockerComposeDown baja un stack docker-compose desde el archivo dado.
// Si removeVolumes es true elimina también los volumes (-v). Retorna el stdout del comando.
func DockerComposeDown(composeFile string, removeVolumes bool) (string, error) {
args := []string{"compose", "-f", composeFile, "down"}
if removeVolumes {
args = append(args, "-v")
}
out, err := exec.Command("docker", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker compose down %s: %s", composeFile, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: docker_compose_down
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerComposeDown(composeFile string, removeVolumes bool) (string, error)"
description: "Baja un stack docker-compose desde el archivo dado. Si removeVolumes es true elimina también los volumes declarados (-v). Retorna el stdout del comando."
tags: [docker, compose, down, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["removeVolumes true agrega flag -v al comando", "error si composeFile no existe"]
test_file_path: "functions/infra/docker_compose_down_test.go"
file_path: "functions/infra/docker_compose_down.go"
---
## Ejemplo
```go
// Bajar stack y limpiar volumes
out, err := DockerComposeDown("./docker-compose.yml", true)
if err != nil {
log.Fatal(err)
}
fmt.Println(out)
```
## Notas
Ejecuta `docker compose -f composeFile down [-v]`. Usa el subcomando `docker compose` (V2). El flag -v elimina volumes nombrados declarados en el compose file, no volumes externos. Retorna stdout + stderr combinados.
@@ -0,0 +1,33 @@
package infra
import (
"os/exec"
"strings"
"testing"
)
func TestDockerComposeDown(t *testing.T) {
t.Run("removeVolumes true agrega flag -v al comando", func(t *testing.T) {
// Verificar que docker compose esta disponible
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
t.Skip("docker compose no disponible")
}
// Con archivo inexistente debe fallar pero con el mensaje correcto
_, err := DockerComposeDown("/ruta/inexistente/docker-compose.yml", true)
if err == nil {
t.Error("se esperaba error para archivo inexistente")
}
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
t.Logf("error obtenido: %v", err)
}
})
t.Run("error si composeFile no existe", func(t *testing.T) {
_, err := DockerComposeDown("/ruta/que/no/existe/docker-compose.yml", false)
if err == nil {
t.Error("se esperaba error para archivo inexistente")
}
})
}
+22
View File
@@ -0,0 +1,22 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DockerComposeUp levanta un stack docker-compose desde el archivo dado.
// Si detach es true ejecuta en background (-d). Retorna el stdout del comando.
func DockerComposeUp(composeFile string, detach bool) (string, error) {
args := []string{"compose", "-f", composeFile, "up"}
if detach {
args = append(args, "-d")
}
out, err := exec.Command("docker", args...).CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker compose up %s: %s", composeFile, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
+35
View File
@@ -0,0 +1,35 @@
---
name: docker_compose_up
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerComposeUp(composeFile string, detach bool) (string, error)"
description: "Levanta un stack docker-compose desde el archivo dado. Si detach es true ejecuta en background (-d). Retorna el stdout del comando."
tags: [docker, compose, up, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["detach true agrega flag -d al comando", "error si composeFile no existe"]
test_file_path: "functions/infra/docker_compose_up_test.go"
file_path: "functions/infra/docker_compose_up.go"
---
## Ejemplo
```go
out, err := DockerComposeUp("./docker-compose.yml", true)
if err != nil {
log.Fatal(err)
}
fmt.Println(out)
```
## Notas
Ejecuta `docker compose -f composeFile up [-d]`. Usa el subcomando `docker compose` (V2), no el binario standalone `docker-compose`. Retorna stdout + stderr combinados para facilitar el debugging. En modo no-detach bloquea hasta que el compose termine (util para stacks efimeros).
+34
View File
@@ -0,0 +1,34 @@
package infra
import (
"os/exec"
"strings"
"testing"
)
func TestDockerComposeUp(t *testing.T) {
t.Run("detach true agrega flag -d al comando", func(t *testing.T) {
// Verificar que docker compose esta disponible
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
t.Skip("docker compose no disponible")
}
// Con archivo inexistente y detach, debe fallar pero con el mensaje correcto
_, err := DockerComposeUp("/ruta/inexistente/docker-compose.yml", true)
if err == nil {
t.Error("se esperaba error para archivo inexistente")
}
// El error debe mencionar el archivo o compose
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
t.Logf("error obtenido: %v", err)
}
})
t.Run("error si composeFile no existe", func(t *testing.T) {
_, err := DockerComposeUp("/ruta/que/no/existe/docker-compose.yml", false)
if err == nil {
t.Error("se esperaba error para archivo inexistente")
}
})
}
+17
View File
@@ -0,0 +1,17 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DockerVolumeCreate crea un volume Docker con el nombre dado.
// Retorna el nombre del volume creado.
func DockerVolumeCreate(name string) (string, error) {
out, err := exec.Command("docker", "volume", "create", name).CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker volume create %s: %s", name, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: docker_volume_create
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerVolumeCreate(name string) (string, error)"
description: "Crea un volume Docker con el nombre dado. Retorna el nombre del volume creado tal como lo confirma Docker."
tags: [docker, volume, create, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["crea volume y retorna nombre", "idempotente si el volume ya existe"]
test_file_path: "functions/infra/docker_volume_create_test.go"
file_path: "functions/infra/docker_volume_create.go"
---
## Ejemplo
```go
volumeName, err := DockerVolumeCreate("postgres_data")
if err != nil {
log.Fatal(err)
}
fmt.Println("Created volume:", volumeName)
// Created volume: postgres_data
```
## Notas
Ejecuta `docker volume create name`. Docker imprime el nombre del volume creado en stdout. Idempotente si el volume ya existe con el mismo nombre.
@@ -0,0 +1,42 @@
package infra
import (
"strings"
"testing"
)
func TestDockerVolumeCreate(t *testing.T) {
t.Run("crea volume y retorna nombre", func(t *testing.T) {
name := "fn-registry-test-vol-create"
got, err := DockerVolumeCreate(name)
if err != nil {
t.Skipf("docker no disponible: %v", err)
}
if strings.TrimSpace(got) != name {
t.Errorf("got %q, want %q", got, name)
}
// Limpiar
_ = DockerVolumeRemove(name, false)
})
t.Run("idempotente si el volume ya existe", func(t *testing.T) {
name := "fn-registry-test-vol-idempotent"
first, err := DockerVolumeCreate(name)
if err != nil {
t.Skipf("docker no disponible: %v", err)
}
defer DockerVolumeRemove(name, false) //nolint
second, err := DockerVolumeCreate(name)
if err != nil {
t.Errorf("segunda llamada fallo: %v", err)
}
if first != second {
t.Errorf("nombres distintos: %q vs %q", first, second)
}
})
}
+35
View File
@@ -0,0 +1,35 @@
package infra
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// DockerVolumeList lista los volumes Docker disponibles localmente.
// Retorna un slice de maps con campos Driver, Name, Scope, Labels, Mountpoint.
func DockerVolumeList() ([]map[string]string, error) {
out, err := exec.Command("docker", "volume", "ls", "--format", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("docker volume ls: %w", err)
}
if len(strings.TrimSpace(string(out))) == 0 {
return nil, nil
}
var volumes []map[string]string
for _, line := range splitLines(out) {
if len(line) == 0 {
continue
}
var raw map[string]string
if err := json.Unmarshal(line, &raw); err != nil {
continue
}
volumes = append(volumes, raw)
}
return volumes, nil
}
+37
View File
@@ -0,0 +1,37 @@
---
name: docker_volume_list
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerVolumeList() ([]map[string]string, error)"
description: "Lista los volumes Docker disponibles localmente. Parsea la salida JSON de docker volume ls. Retorna slice de maps con campos Driver, Name, Scope, Labels, Mountpoint."
tags: [docker, volume, list, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [encoding/json, fmt, os/exec, strings]
tested: true
tests: ["lista vacia retorna nil sin error", "parsea campos Driver y Name correctamente"]
test_file_path: "functions/infra/docker_volume_list_test.go"
file_path: "functions/infra/docker_volume_list.go"
---
## Ejemplo
```go
volumes, err := DockerVolumeList()
if err != nil {
log.Fatal(err)
}
for _, v := range volumes {
fmt.Printf("Volume: %s (driver: %s)\n", v["Name"], v["Driver"])
}
```
## Notas
Ejecuta `docker volume ls --format {{json .}}` (un JSON por linea). Usa splitLines del paquete infra para iterar lineas. Retorna nil si no hay volumes. Los campos del map dependen de la version de Docker pero siempre incluyen Driver y Name.
@@ -0,0 +1,45 @@
package infra
import (
"testing"
)
func TestDockerVolumeList(t *testing.T) {
t.Run("lista vacia retorna nil sin error", func(t *testing.T) {
// Verificar que la funcion no falla incluso si no hay volumes
volumes, err := DockerVolumeList()
if err != nil {
t.Skipf("docker no disponible: %v", err)
}
// volumes puede ser nil o no segun el sistema — no es un error
_ = volumes
})
t.Run("parsea campos Driver y Name correctamente", func(t *testing.T) {
name := "fn-registry-test-vol-list"
_, err := DockerVolumeCreate(name)
if err != nil {
t.Skipf("docker no disponible: %v", err)
}
defer DockerVolumeRemove(name, false) //nolint
volumes, err := DockerVolumeList()
if err != nil {
t.Fatalf("DockerVolumeList: %v", err)
}
found := false
for _, v := range volumes {
if v["Name"] == name {
found = true
if v["Driver"] == "" {
t.Error("Driver vacio para volume existente")
}
break
}
}
if !found {
t.Errorf("volume %q no encontrado en la lista", name)
}
})
}
+23
View File
@@ -0,0 +1,23 @@
package infra
import (
"fmt"
"os/exec"
"strings"
)
// DockerVolumeRemove elimina un volume Docker por nombre.
// Si force es true, fuerza la eliminación incluso si está en uso.
func DockerVolumeRemove(name string, force bool) error {
args := []string{"volume", "rm"}
if force {
args = append(args, "-f")
}
args = append(args, name)
out, err := exec.Command("docker", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("docker volume rm %s: %s", name, strings.TrimSpace(string(out)))
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
---
name: docker_volume_remove
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerVolumeRemove(name string, force bool) error"
description: "Elimina un volume Docker por nombre. Si force es true fuerza la eliminación aunque esté en uso."
tags: [docker, volume, remove, delete, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strings]
tested: true
tests: ["error si volume no existe", "force flag incluye -f en el comando"]
test_file_path: "functions/infra/docker_volume_remove_test.go"
file_path: "functions/infra/docker_volume_remove.go"
---
## Ejemplo
```go
// Eliminar volume con fuerza
err := DockerVolumeRemove("postgres_data", true)
if err != nil {
log.Fatal(err)
}
fmt.Println("Volume eliminado")
```
## Notas
Ejecuta `docker volume rm [-f] name`. El flag -f solo esta disponible en versiones recientes de Docker. Sin force, falla si el volume esta siendo usado por un contenedor activo.
@@ -0,0 +1,27 @@
package infra
import (
"testing"
)
func TestDockerVolumeRemove(t *testing.T) {
t.Run("error si volume no existe", func(t *testing.T) {
err := DockerVolumeRemove("fn-registry-vol-que-no-existe-xyz", false)
if err == nil {
t.Error("se esperaba error para volume inexistente")
}
})
t.Run("force flag incluye -f en el comando", func(t *testing.T) {
name := "fn-registry-test-vol-remove"
_, err := DockerVolumeCreate(name)
if err != nil {
t.Skipf("docker no disponible: %v", err)
}
err = DockerVolumeRemove(name, true)
if err != nil {
t.Errorf("DockerVolumeRemove con force: %v", err)
}
})
}
+53
View File
@@ -0,0 +1,53 @@
package infra
import (
"fmt"
"sort"
"strings"
)
// GenerateDockerfile genera el texto de un Dockerfile multi-stage para una app Go.
// Stage build: golang:1.23-alpine — descarga dependencias y compila.
// Stage final: alpine:latest — copia el binario, expone el puerto y lo ejecuta.
// Las envVars se inyectan como instrucciones ENV en el stage final.
// Funcion pura: no realiza I/O, solo genera texto.
func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string {
var sb strings.Builder
// Stage build
sb.WriteString("# Stage build\n")
sb.WriteString("FROM golang:1.23-alpine AS builder\n\n")
sb.WriteString("WORKDIR /app\n\n")
sb.WriteString("COPY go.mod go.sum ./\n")
sb.WriteString("RUN go mod download\n\n")
sb.WriteString("COPY . .\n")
sb.WriteString(fmt.Sprintf("RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o %s .\n\n", binaryName))
// Stage final
sb.WriteString("# Stage final\n")
sb.WriteString("FROM alpine:latest\n\n")
sb.WriteString("RUN apk --no-cache add ca-certificates tzdata\n\n")
sb.WriteString("WORKDIR /app\n\n")
sb.WriteString(fmt.Sprintf("COPY --from=builder /app/%s .\n\n", binaryName))
// ENV vars (orden determinista)
if len(envVars) > 0 {
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sb.WriteString(fmt.Sprintf("ENV %s=%s\n", k, envVars[k]))
}
sb.WriteString("\n")
}
if port > 0 {
sb.WriteString(fmt.Sprintf("EXPOSE %d\n\n", port))
}
sb.WriteString(fmt.Sprintf("ENTRYPOINT [\"./%s\"]\n", binaryName))
return sb.String()
}
+39
View File
@@ -0,0 +1,39 @@
---
name: generate_dockerfile
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string"
description: "Genera el texto de un Dockerfile multi-stage para una app Go. Stage build con golang:1.23-alpine, stage final con alpine:latest. Incluye ENV vars del map con orden determinista. Funcion pura sin I/O."
tags: [docker, dockerfile, go, build, deploy, infra, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [fmt, sort, strings]
tested: true
tests: ["contiene stage builder con golang:1.23-alpine", "contiene stage final con alpine:latest", "incluye EXPOSE cuando port mayor a cero", "no incluye EXPOSE cuando port es cero", "env vars aparecen ordenadas alfabeticamente", "binaryName aparece en ENTRYPOINT", "env vars vacias no generan instrucciones ENV"]
test_file_path: "functions/infra/generate_dockerfile_test.go"
file_path: "functions/infra/generate_dockerfile.go"
---
## Ejemplo
```go
content := GenerateDockerfile("myapp", 8080, map[string]string{
"DB_HOST": "localhost",
"PORT": "8080",
})
fmt.Println(content)
// FROM golang:1.23-alpine AS builder
// ...
// EXPOSE 8080
// ENTRYPOINT ["./myapp"]
```
## Notas
Funcion pura — no toca el sistema de archivos. Componer con WriteDockerfile para persistir el resultado. Las ENV vars se ordenan alfabeticamente para garantizar Dockerfiles deterministas (mismo input => mismo output exacto). El stage build usa CGO_ENABLED=0 para binarios estáticos compatibles con alpine. Si port <= 0, omite la instruccion EXPOSE.
@@ -0,0 +1,67 @@
package infra
import (
"strings"
"testing"
)
func TestGenerateDockerfile(t *testing.T) {
t.Run("contiene stage builder con golang:1.23-alpine", func(t *testing.T) {
got := GenerateDockerfile("myapp", 8080, nil)
if !strings.Contains(got, "FROM golang:1.23-alpine AS builder") {
t.Errorf("expected FROM golang:1.23-alpine AS builder, got:\n%s", got)
}
})
t.Run("contiene stage final con alpine:latest", func(t *testing.T) {
got := GenerateDockerfile("myapp", 8080, nil)
if !strings.Contains(got, "FROM alpine:latest") {
t.Errorf("expected FROM alpine:latest, got:\n%s", got)
}
})
t.Run("incluye EXPOSE cuando port mayor a cero", func(t *testing.T) {
got := GenerateDockerfile("myapp", 8080, nil)
if !strings.Contains(got, "EXPOSE 8080") {
t.Errorf("expected EXPOSE 8080, got:\n%s", got)
}
})
t.Run("no incluye EXPOSE cuando port es cero", func(t *testing.T) {
got := GenerateDockerfile("myapp", 0, nil)
if strings.Contains(got, "EXPOSE") {
t.Errorf("expected no EXPOSE directive, got:\n%s", got)
}
})
t.Run("env vars aparecen ordenadas alfabeticamente", func(t *testing.T) {
got := GenerateDockerfile("myapp", 8080, map[string]string{
"Z_VAR": "z",
"A_VAR": "a",
"M_VAR": "m",
})
posA := strings.Index(got, "ENV A_VAR=a")
posM := strings.Index(got, "ENV M_VAR=m")
posZ := strings.Index(got, "ENV Z_VAR=z")
if posA < 0 || posM < 0 || posZ < 0 {
t.Fatalf("ENV vars no encontradas en:\n%s", got)
}
if !(posA < posM && posM < posZ) {
t.Errorf("ENV vars no ordenadas: A=%d M=%d Z=%d", posA, posM, posZ)
}
})
t.Run("binaryName aparece en ENTRYPOINT", func(t *testing.T) {
got := GenerateDockerfile("mycli", 9090, nil)
if !strings.Contains(got, `ENTRYPOINT ["./mycli"]`) {
t.Errorf("expected ENTRYPOINT with mycli, got:\n%s", got)
}
})
t.Run("env vars vacias no generan instrucciones ENV", func(t *testing.T) {
got := GenerateDockerfile("myapp", 8080, map[string]string{})
if strings.Contains(got, "ENV ") {
t.Errorf("expected no ENV directives for empty map, got:\n%s", got)
}
})
}
+42
View File
@@ -0,0 +1,42 @@
package infra
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GoBuildBinary compila un binario Go desde projectDir hacia outputPath.
// Si ldflags está vacío usa "-s -w" (strip debug info). Si outputPath está vacío
// usa "build/{dirname}" dentro del projectDir. Requiere Go instalado en PATH.
func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error {
if ldflags == "" {
ldflags = "-s -w"
}
if outputPath == "" {
outputPath = filepath.Join(projectDir, "build", filepath.Base(projectDir))
}
// Crear directorio destino si no existe
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return fmt.Errorf("go_build_binary: crear directorio destino %s: %w", filepath.Dir(outputPath), err)
}
args := []string{"build", "-trimpath", "-ldflags=" + ldflags}
if tags != "" {
args = append(args, "-tags="+tags)
}
args = append(args, "-o", outputPath, ".")
cmd := exec.Command("go", args...)
cmd.Dir = projectDir
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("go build en %s: %s", projectDir, strings.TrimSpace(string(out)))
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
---
name: go_build_binary
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error"
description: "Compila un binario Go desde un directorio de proyecto. Si ldflags está vacío usa -s -w (strip debug). Si outputPath está vacío usa build/{dirname} dentro del projectDir. Ejecuta con CGO_ENABLED=0."
tags: [go, build, binary, compile, infra, deploy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, os/exec, path/filepath, strings]
tested: true
tests: ["compila proyecto valido sin error", "outputPath vacio usa build/dirname por defecto", "ldflags vacio usa -s -w por defecto", "error si projectDir no existe"]
test_file_path: "functions/infra/go_build_binary_test.go"
file_path: "functions/infra/go_build_binary.go"
---
## Ejemplo
```go
// Compilar con opciones por defecto
err := GoBuildBinary("/home/user/apps/myapp", "", "", "")
// genera /home/user/apps/myapp/build/myapp
// Compilar con output y tags explícitos
err = GoBuildBinary("/home/user/apps/myapp", "/tmp/myapp-bin", "-s -w -X main.version=1.0", "fts5")
if err != nil {
log.Fatal(err)
}
```
## Notas
Usa `CGO_ENABLED=0` para binarios estáticos compatibles con imágenes alpine. El flag `-trimpath` elimina rutas absolutas del binario para reproducibilidad. Los flags `-s -w` reducen el tamaño eliminando información de debug y la tabla de símbolos. Compatible con el flujo deploy_app que genera Dockerfile multi-stage.
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestGoBuildBinary(t *testing.T) {
t.Run("compila proyecto valido sin error", func(t *testing.T) {
// Crear un proyecto Go mínimo en un directorio temporal
tmpDir := t.TempDir()
goModContent := "module testapp\n\ngo 1.21\n"
mainContent := `package main
import "fmt"
func main() {
fmt.Println("hello")
}
`
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
t.Fatal(err)
}
outputPath := filepath.Join(tmpDir, "bin", "testapp")
err := GoBuildBinary(tmpDir, outputPath, "", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Verificar que el binario existe
if _, statErr := os.Stat(outputPath); os.IsNotExist(statErr) {
t.Errorf("binary not found at %s", outputPath)
}
})
t.Run("outputPath vacio usa build/dirname por defecto", func(t *testing.T) {
tmpDir := t.TempDir()
goModContent := "module myproject\n\ngo 1.21\n"
mainContent := "package main\n\nfunc main() {}\n"
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
t.Fatal(err)
}
err := GoBuildBinary(tmpDir, "", "", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
expectedPath := filepath.Join(tmpDir, "build", filepath.Base(tmpDir))
if _, statErr := os.Stat(expectedPath); os.IsNotExist(statErr) {
t.Errorf("expected binary at %s, not found", expectedPath)
}
})
t.Run("ldflags vacio usa -s -w por defecto", func(t *testing.T) {
// Este test verifica que la función no falla con ldflags vacío
// (los flags por defecto -s -w son válidos para go build)
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module x\n\ngo 1.21\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
t.Fatal(err)
}
outputPath := filepath.Join(tmpDir, "out")
err := GoBuildBinary(tmpDir, outputPath, "", "")
if err != nil {
t.Errorf("ldflags vacío debería usar -s -w y compilar sin error: %v", err)
}
})
t.Run("error si projectDir no existe", func(t *testing.T) {
err := GoBuildBinary("/nonexistent/path/to/project", "/tmp/out", "", "")
if err == nil {
t.Error("expected error for nonexistent projectDir, got nil")
}
})
}
+38
View File
@@ -0,0 +1,38 @@
package infra
import (
"fmt"
"net/http"
"time"
)
// HealthCheckHTTP hace polling HTTP GET a url hasta recibir status 200
// o hasta que pasen timeoutSecs segundos. Entre intentos espera intervalMs milisegundos.
func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error {
deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second)
interval := time.Duration(intervalMs) * time.Millisecond
client := &http.Client{
Timeout: time.Duration(intervalMs)*time.Millisecond + 500*time.Millisecond,
}
var lastErr error
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
lastErr = fmt.Errorf("status %d", resp.StatusCode)
} else {
lastErr = err
}
time.Sleep(interval)
}
if lastErr != nil {
return fmt.Errorf("health check %s timeout after %ds: %w", url, timeoutSecs, lastErr)
}
return fmt.Errorf("health check %s timeout after %ds", url, timeoutSecs)
}
+36
View File
@@ -0,0 +1,36 @@
---
name: health_check_http
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error"
description: "Hace polling HTTP GET a un endpoint hasta recibir status 200 o hasta agotar el timeout. Útil para esperar que un servicio levante antes de continuar un pipeline."
tags: [http, health, check, polling, wait, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, net/http, time]
tested: true
tests: ["retorna nil cuando el servidor responde 200", "retorna error si el timeout se agota", "respeta el intervalo entre intentos"]
test_file_path: "functions/infra/health_check_http_test.go"
file_path: "functions/infra/health_check_http.go"
---
## Ejemplo
```go
// Esperar hasta 60s a que Metabase levante, polling cada 2s
err := HealthCheckHTTP("http://localhost:3000/api/health", 60, 2000)
if err != nil {
log.Fatal("Servicio no disponible:", err)
}
fmt.Println("Servicio listo")
```
## Notas
Usa solo net/http de la stdlib, sin dependencias externas. El cliente HTTP tiene timeout de intervalMs + 500ms para no bloquear el loop. Retorna el ultimo error si el timeout expira. No sigue redirects especiales — cualquier respuesta 200 OK es exito.
+63
View File
@@ -0,0 +1,63 @@
package infra
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHealthCheckHTTP(t *testing.T) {
t.Run("retorna nil cuando el servidor responde 200", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
err := HealthCheckHTTP(srv.URL, 5, 100)
if err != nil {
t.Errorf("se esperaba nil, got: %v", err)
}
})
t.Run("retorna error si el timeout se agota", func(t *testing.T) {
// Servidor que nunca responde 200
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
err := HealthCheckHTTP(srv.URL, 1, 200)
if err == nil {
t.Error("se esperaba error por timeout")
}
})
t.Run("respeta el intervalo entre intentos", func(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts >= 3 {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
defer srv.Close()
start := time.Now()
err := HealthCheckHTTP(srv.URL, 5, 150)
elapsed := time.Since(start)
if err != nil {
t.Errorf("se esperaba nil despues de 3 intentos, got: %v", err)
}
// Con 2 intervalos de 150ms debe haber pasado al menos 250ms
if elapsed < 250*time.Millisecond {
t.Errorf("elapsed %v demasiado rapido, se esperaban al menos 250ms", elapsed)
}
if attempts < 3 {
t.Errorf("se esperaban al menos 3 intentos, got %d", attempts)
}
})
}
+30
View File
@@ -0,0 +1,30 @@
package infra
import (
"fmt"
)
// StopApp para y elimina el contenedor de una app desplegada.
// Si removeImage es true, elimina también la imagen Docker asociada.
// containerName debe coincidir con el nombre usado en DeployApp (= imageName).
func StopApp(containerName string, removeImage bool) error {
// 1. Detener el contenedor (timeout 10s)
if err := DockerStopContainer(containerName, 10); err != nil {
return fmt.Errorf("stop_app: detener contenedor %s: %w", containerName, err)
}
// 2. Eliminar el contenedor
if err := DockerRemoveContainer(containerName, false); err != nil {
return fmt.Errorf("stop_app: eliminar contenedor %s: %w", containerName, err)
}
// 3. Eliminar la imagen si se solicita
if removeImage {
imageName := containerName + ":latest"
if err := DockerRemoveImage(imageName, false); err != nil {
return fmt.Errorf("stop_app: eliminar imagen %s: %w", imageName, err)
}
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
---
name: stop_app
kind: pipeline
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func StopApp(containerName string, removeImage bool) error"
description: "Para y elimina el contenedor de una app desplegada. Si removeImage es true elimina también la imagen Docker. containerName debe coincidir con el imageName usado en deploy_app."
tags: [docker, stop, remove, deploy, pipeline, infra, container]
uses_functions: [docker_stop_container_go_infra, docker_remove_container_go_infra, docker_remove_image_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/stop_app.go"
---
## Ejemplo
```go
// Parar contenedor sin eliminar la imagen (para relanzar rapido)
err := StopApp("myapp", false)
// Parar y limpiar todo
err = StopApp("myapp", true)
if err != nil {
log.Fatal(err)
}
```
## Notas
Inverso de deploy_app. El contenedor se detiene con 10 segundos de gracia antes de SIGKILL. La imagen se busca como containerName:latest (convencion de deploy_app). Si solo se quiere parar sin limpiar, usar removeImage=false para conservar la imagen en cache local y acelerar el siguiente deploy.
+27
View File
@@ -0,0 +1,27 @@
package infra
import (
"fmt"
"os"
"path/filepath"
)
// WriteDockerfile escribe content en dir/Dockerfile.
// Crea el directorio si no existe. Retorna el path absoluto del archivo escrito.
func WriteDockerfile(dir, content string) (string, error) {
absDir, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("write_dockerfile: resolver path %s: %w", dir, err)
}
if err := os.MkdirAll(absDir, 0o755); err != nil {
return "", fmt.Errorf("write_dockerfile: crear directorio %s: %w", absDir, err)
}
dockerfilePath := filepath.Join(absDir, "Dockerfile")
if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write_dockerfile: escribir %s: %w", dockerfilePath, err)
}
return dockerfilePath, nil
}
+38
View File
@@ -0,0 +1,38 @@
---
name: write_dockerfile
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WriteDockerfile(dir, content string) (string, error)"
description: "Escribe content en dir/Dockerfile. Crea el directorio si no existe. Retorna el path absoluto del archivo escrito. Compañera impura de generate_dockerfile."
tags: [docker, dockerfile, io, write, deploy, infra]
uses_functions: [generate_dockerfile_go_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os, path/filepath]
tested: true
tests: ["escribe Dockerfile en directorio existente", "crea directorio si no existe", "retorna path absoluto correcto", "error si dir es path invalido"]
test_file_path: "functions/infra/write_dockerfile_test.go"
file_path: "functions/infra/write_dockerfile.go"
---
## Ejemplo
```go
// Patron puro+impuro: generar contenido y luego escribir
content := GenerateDockerfile("myapp", 8080, map[string]string{"PORT": "8080"})
path, err := WriteDockerfile("/home/user/apps/myapp", content)
if err != nil {
log.Fatal(err)
}
fmt.Println("Dockerfile escrito en:", path)
// /home/user/apps/myapp/Dockerfile
```
## Notas
Patron puro+impuro: generate_dockerfile produce el texto (pura, testeable sin I/O), write_dockerfile lo persiste (impura, efecto secundario aislado). Esto facilita testear la generacion del contenido independientemente de la escritura. Sobreescribe cualquier Dockerfile existente en el directorio.
+78
View File
@@ -0,0 +1,78 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteDockerfile(t *testing.T) {
t.Run("escribe Dockerfile en directorio existente", func(t *testing.T) {
tmpDir := t.TempDir()
content := "FROM alpine:latest\nCMD [\"sh\"]\n"
path, err := WriteDockerfile(tmpDir, content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("cannot read written file: %v", err)
}
if string(data) != content {
t.Errorf("content mismatch: got %q, want %q", string(data), content)
}
})
t.Run("crea directorio si no existe", func(t *testing.T) {
tmpDir := t.TempDir()
newDir := filepath.Join(tmpDir, "subdir", "nested")
content := "FROM scratch\n"
path, err := WriteDockerfile(newDir, content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(newDir); os.IsNotExist(statErr) {
t.Error("expected directory to be created")
}
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
t.Errorf("expected Dockerfile at %s", path)
}
})
t.Run("retorna path absoluto correcto", func(t *testing.T) {
tmpDir := t.TempDir()
content := "FROM ubuntu:22.04\n"
path, err := WriteDockerfile(tmpDir, content)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !filepath.IsAbs(path) {
t.Errorf("expected absolute path, got: %s", path)
}
if !strings.HasSuffix(path, "Dockerfile") {
t.Errorf("expected path to end with Dockerfile, got: %s", path)
}
})
t.Run("error si dir es path invalido", func(t *testing.T) {
// Intentar escribir en un path donde el padre es un archivo (no directorio)
tmpDir := t.TempDir()
// Crear un archivo donde esperamos un directorio
blockerPath := filepath.Join(tmpDir, "blocker")
if err := os.WriteFile(blockerPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// Intentar usar ese archivo como directorio
_, err := WriteDockerfile(filepath.Join(blockerPath, "subdir"), "FROM scratch\n")
if err == nil {
t.Error("expected error when dir path goes through a file, got nil")
}
})
}
@@ -0,0 +1,52 @@
---
name: win_firewall_add_rule
kind: function
lang: ps
domain: infra
version: "1.0.0"
purity: impure
signature: "win_firewall_add_rule -Name <string> -Port <int> [-Protocol <string>]"
description: "Añade una regla de entrada al firewall de Windows para permitir tráfico en un puerto TCP/UDP. Si ya existe una regla con el mismo nombre, la elimina y la recrea. Requiere privilegios de Administrador."
tags: [firewall, windows, netsh, network, infra, security, port, wsl2]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "powershell/functions/infra/win_firewall_add_rule.ps1"
---
## Uso
```powershell
# Desde PowerShell (como Administrador)
.\win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
# Con protocolo explícito
.\win_firewall_add_rule.ps1 -Name "MyApp-UDP-5000" -Port 5000 -Protocol UDP
```
```bash
# Desde WSL2
powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
```
## Parametros
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|------------|--------|-------------|---------|--------------------------------------|
| `-Name` | string | si | — | Nombre de la regla en el firewall |
| `-Port` | int | si | — | Puerto local (1-65535) |
| `-Protocol`| string | no | TCP | Protocolo: TCP o UDP |
## Notas
- Requiere ejecutarse como **Administrador** en Windows. Si se llama desde WSL2, usar `powershell.exe` (que corre en Windows) o `pwsh.exe`.
- Si ya existe una regla con el mismo nombre, la elimina primero y la recrea (idempotente respecto al nombre).
- La regla creada es `dir=in action=allow` — solo aplica para tráfico entrante.
- Caso de uso principal: permitir que WSL2 alcance Chrome CDP en el host Windows cuando Chrome escucha en `127.0.0.1:9222`.
- Retorna exit code 0 si tuvo éxito, 1 si hubo error (falta de privilegios, puerto inválido, fallo de netsh).
@@ -0,0 +1,56 @@
# win_firewall_add_rule.ps1 - Adds a Windows Firewall inbound rule for a TCP/UDP port.
# Requires: Administrator privileges
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[int]$Port,
[Parameter(Mandatory = $false)]
[string]$Protocol = "TCP"
)
# Verify administrator privileges
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
exit 1
}
# Validate protocol
$validProtocols = @("TCP", "UDP")
if ($validProtocols -notcontains $Protocol.ToUpper()) {
Write-Error "ERROR: Protocol must be TCP or UDP, got '$Protocol'."
exit 1
}
# Validate port range
if ($Port -lt 1 -or $Port -gt 65535) {
Write-Error "ERROR: Port must be between 1 and 65535, got '$Port'."
exit 1
}
# Remove existing rule with the same name if it exists
$existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Removing existing rule '$Name'..."
netsh advfirewall firewall delete rule name="$Name" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to remove existing rule '$Name'."
exit 1
}
}
# Add the new inbound rule
Write-Host "Adding firewall rule '$Name' for $Protocol port $Port..."
netsh advfirewall firewall add rule name="$Name" dir=in action=allow protocol=$Protocol localport=$Port
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to add firewall rule '$Name'."
exit 1
}
Write-Host "OK: Firewall rule '$Name' added - $Protocol inbound on port $Port."
@@ -0,0 +1,46 @@
---
name: win_firewall_remove_rule
kind: function
lang: ps
domain: infra
version: "1.0.0"
purity: impure
signature: "win_firewall_remove_rule -Name <string>"
description: "Elimina una regla del firewall de Windows por nombre. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador."
tags: [firewall, windows, netsh, network, infra, security, cleanup, wsl2]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "powershell/functions/infra/win_firewall_remove_rule.ps1"
---
## Uso
```powershell
# Desde PowerShell (como Administrador)
.\win_firewall_remove_rule.ps1 -Name "CDP-9222"
```
```bash
# Desde WSL2
powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
```
## Parametros
| Parametro | Tipo | Obligatorio | Descripcion |
|-----------|--------|-------------|-----------------------------------|
| `-Name` | string | si | Nombre exacto de la regla a eliminar |
## Notas
- Requiere ejecutarse como **Administrador** en Windows.
- Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
- Complementa `win_firewall_add_rule` para teardown limpio de reglas temporales.
- Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.
@@ -0,0 +1,33 @@
# win_firewall_remove_rule.ps1 - Removes a Windows Firewall rule by name.
# Requires: Administrator privileges
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
# Verify administrator privileges
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
exit 1
}
# Check if the rule exists
$existingRule = netsh advfirewall firewall show rule name="$Name" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "INFO: Firewall rule '$Name' does not exist - nothing to remove."
exit 0
}
# Remove the rule
Write-Host "Removing firewall rule '$Name'..."
netsh advfirewall firewall delete rule name="$Name"
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to remove firewall rule '$Name'."
exit 1
}
Write-Host "OK: Firewall rule '$Name' removed."
@@ -0,0 +1,73 @@
---
name: win_portproxy_add
kind: function
lang: ps
domain: infra
version: "1.0.0"
purity: impure
signature: "win_portproxy_add -ListenPort <int> [-ConnectPort <int>] [-ListenAddr <string>] [-ConnectAddr <string>]"
description: "Añade una regla de port proxy v4tov4 con netsh para redirigir tráfico desde ListenAddr:ListenPort hacia ConnectAddr:ConnectPort. Si ya existe una regla para el mismo listenaddress:listenport, la elimina y la recrea. Requiere privilegios de Administrador."
tags: [portproxy, netsh, windows, network, infra, wsl2, cdp, redirect, proxy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "powershell/functions/infra/win_portproxy_add.ps1"
---
## Uso
```powershell
# Caso típico WSL2 → Chrome CDP: redirigir 0.0.0.0:9222 → 127.0.0.1:9222
.\win_portproxy_add.ps1 -ListenPort 9222
# Con todos los parámetros explícitos
.\win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1
# Redirigir a un puerto diferente
.\win_portproxy_add.ps1 -ListenPort 8080 -ConnectPort 3000
```
```bash
# Desde WSL2 — habilitar acceso a Chrome CDP en el host Windows
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
```
## Parametros
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|----------------|--------|-------------|---------------|--------------------------------------------------|
| `-ListenPort` | int | si | — | Puerto en el que escucha el proxy (1-65535) |
| `-ConnectPort` | int | no | =ListenPort | Puerto de destino al que conectar (1-65535) |
| `-ListenAddr` | string | no | `0.0.0.0` | Dirección IP en la que escucha el proxy |
| `-ConnectAddr` | string | no | `127.0.0.1` | Dirección IP de destino |
## Notas
- Requiere ejecutarse como **Administrador** en Windows.
- Caso de uso principal: WSL2 no puede alcanzar `127.0.0.1` del host Windows directamente. Esta regla hace que el host escuche en `0.0.0.0:PORT` y reenvíe al loopback donde Chrome (u otro proceso) está escuchando.
- Idempotente respecto a listenaddress:listenport: si ya existe una regla para esa combinación, la elimina y la recrea.
- Combinar con `win_firewall_add_rule` para que el firewall también permita el tráfico entrante.
- Retorna exit code 0 si tuvo éxito, 1 si hubo error.
## Flujo completo WSL2 → Chrome CDP
```bash
# 1. Abrir Puerto en firewall
powershell.exe -ExecutionPolicy Bypass -File win_firewall_add_rule.ps1 -Name "CDP-9222" -Port 9222
# 2. Redirigir tráfico de red al loopback
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
# 3. Arrancar Chrome con CDP en Windows
# chrome.exe --remote-debugging-port=9222 --headless
# 4. Desde WSL2 conectar al host Windows
# WSL2_HOST=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}')
# curl http://$WSL2_HOST:9222/json
```
@@ -0,0 +1,62 @@
# win_portproxy_add.ps1 - Adds a netsh portproxy rule (v4tov4) to forward traffic.
# Requires: Administrator privileges
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222
# powershell.exe -ExecutionPolicy Bypass -File win_portproxy_add.ps1 -ListenPort 9222 -ConnectPort 9222 -ListenAddr 0.0.0.0 -ConnectAddr 127.0.0.1
param(
[Parameter(Mandatory = $true)]
[int]$ListenPort,
[Parameter(Mandatory = $false)]
[int]$ConnectPort = 0, # 0 means use ListenPort
[Parameter(Mandatory = $false)]
[string]$ListenAddr = "0.0.0.0",
[Parameter(Mandatory = $false)]
[string]$ConnectAddr = "127.0.0.1"
)
# Verify administrator privileges
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
exit 1
}
# Default ConnectPort to ListenPort if not specified
if ($ConnectPort -eq 0) {
$ConnectPort = $ListenPort
}
# Validate ports
if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
exit 1
}
if ($ConnectPort -lt 1 -or $ConnectPort -gt 65535) {
Write-Error "ERROR: ConnectPort must be between 1 and 65535, got '$ConnectPort'."
exit 1
}
# Remove existing portproxy for the same listenaddress:listenport if it exists
$existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
if ($existing) {
Write-Host "Removing existing portproxy for ${ListenAddr}:${ListenPort}..."
netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to remove existing portproxy for ${ListenAddr}:${ListenPort}."
exit 1
}
}
# Add the portproxy rule
Write-Host "Adding portproxy: ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}..."
netsh interface portproxy add v4tov4 listenaddress=$ListenAddr listenport=$ListenPort connectaddress=$ConnectAddr connectport=$ConnectPort
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to add portproxy ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
exit 1
}
Write-Host "OK: Portproxy added - ${ListenAddr}:${ListenPort} -> ${ConnectAddr}:${ConnectPort}."
@@ -0,0 +1,61 @@
---
name: win_portproxy_remove
kind: function
lang: ps
domain: infra
version: "1.0.0"
purity: impure
signature: "win_portproxy_remove -ListenPort <int> [-ListenAddr <string>]"
description: "Elimina una regla de port proxy v4tov4 de netsh identificada por ListenAddr:ListenPort. Si la regla no existe, termina con éxito sin hacer nada (idempotente). Requiere privilegios de Administrador."
tags: [portproxy, netsh, windows, network, infra, wsl2, cleanup, proxy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "powershell/functions/infra/win_portproxy_remove.ps1"
---
## Uso
```powershell
# Eliminar portproxy en puerto 9222 (listenaddr por defecto: 0.0.0.0)
.\win_portproxy_remove.ps1 -ListenPort 9222
# Con listenaddr explícito
.\win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
```
```bash
# Desde WSL2
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
```
## Parametros
| Parametro | Tipo | Obligatorio | Default | Descripcion |
|---------------|--------|-------------|-------------|------------------------------------------------------|
| `-ListenPort` | int | si | — | Puerto de escucha de la regla a eliminar (1-65535) |
| `-ListenAddr` | string | no | `0.0.0.0` | Dirección IP de escucha de la regla a eliminar |
## Notas
- Requiere ejecutarse como **Administrador** en Windows.
- Idempotente: si la regla no existe, sale con exit code 0 y mensaje informativo.
- Complementa `win_portproxy_add` para teardown limpio.
- La regla se identifica por la combinación `listenaddress:listenport` — debe coincidir exactamente con la usada en `win_portproxy_add`.
- Retorna exit code 0 si tuvo éxito (o la regla no existía), 1 si hubo error.
## Teardown completo WSL2 → Chrome CDP
```bash
# 1. Eliminar portproxy
powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
# 2. Eliminar regla de firewall
powershell.exe -ExecutionPolicy Bypass -File win_firewall_remove_rule.ps1 -Name "CDP-9222"
```
@@ -0,0 +1,43 @@
# win_portproxy_remove.ps1 - Removes a netsh portproxy v4tov4 rule.
# Requires: Administrator privileges
# Usage: powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenPort 9222
# powershell.exe -ExecutionPolicy Bypass -File win_portproxy_remove.ps1 -ListenAddr 0.0.0.0 -ListenPort 9222
param(
[Parameter(Mandatory = $true)]
[int]$ListenPort,
[Parameter(Mandatory = $false)]
[string]$ListenAddr = "0.0.0.0"
)
# Verify administrator privileges
$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "ERROR: This script requires Administrator privileges. Run PowerShell as Administrator."
exit 1
}
# Validate port
if ($ListenPort -lt 1 -or $ListenPort -gt 65535) {
Write-Error "ERROR: ListenPort must be between 1 and 65535, got '$ListenPort'."
exit 1
}
# Check if the portproxy rule exists
$existing = netsh interface portproxy show v4tov4 2>&1 | Select-String "$ListenAddr\s+$ListenPort"
if (-not $existing) {
Write-Host "INFO: Portproxy for ${ListenAddr}:${ListenPort} does not exist - nothing to remove."
exit 0
}
# Remove the portproxy rule
Write-Host "Removing portproxy for ${ListenAddr}:${ListenPort}..."
netsh interface portproxy delete v4tov4 listenaddress=$ListenAddr listenport=$ListenPort
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: Failed to remove portproxy for ${ListenAddr}:${ListenPort}."
exit 1
}
Write-Host "OK: Portproxy for ${ListenAddr}:${ListenPort} removed."
+101
View File
@@ -0,0 +1,101 @@
package registry
import (
"crypto/sha256"
"fmt"
"time"
)
// timestampRecord holds preserved timestamps and hash for an existing entry.
type timestampRecord struct {
CreatedAt time.Time
UpdatedAt time.Time
ContentHash string
}
// ComputeFunctionHash computes a deterministic hash of all content fields of a Function
// (excluding created_at, updated_at, and content_hash itself).
func ComputeFunctionHash(f *Function) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s",
f.ID, f.Name, f.Kind, f.Lang, f.Domain, f.Version, f.Purity, f.Signature, f.Description)
fmt.Fprintf(h, "|%s", marshalStrings(f.Tags))
fmt.Fprintf(h, "|%s", marshalStrings(f.UsesFunctions))
fmt.Fprintf(h, "|%s", marshalStrings(f.UsesTypes))
fmt.Fprintf(h, "|%s", marshalStrings(f.Returns))
fmt.Fprintf(h, "|%t|%s", f.ReturnsOptional, f.ErrorType)
fmt.Fprintf(h, "|%s", marshalStrings(f.Imports))
fmt.Fprintf(h, "|%s|%t", f.Example, f.Tested)
fmt.Fprintf(h, "|%s", marshalStrings(f.Tests))
fmt.Fprintf(h, "|%s|%s", f.TestFilePath, f.FilePath)
fmt.Fprintf(h, "|%s", marshalProps(f.Props))
fmt.Fprintf(h, "|%s", marshalStrings(f.Emits))
if f.HasState != nil {
fmt.Fprintf(h, "|%t", *f.HasState)
}
fmt.Fprintf(h, "|%s", f.Framework)
fmt.Fprintf(h, "|%s", marshalStrings(f.Variant))
fmt.Fprintf(h, "|%s|%s|%s", f.Notes, f.Documentation, f.Code)
fmt.Fprintf(h, "|%s|%s|%s", f.SourceRepo, f.SourceLicense, f.SourceFile)
return fmt.Sprintf("%x", h.Sum(nil))
}
// ComputeTypeHash computes a deterministic hash of all content fields of a Type.
func ComputeTypeHash(t *Type) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s",
t.ID, t.Name, t.Lang, t.Domain, t.Version, t.Algebraic, t.Definition, t.Description)
fmt.Fprintf(h, "|%s", marshalStrings(t.Tags))
fmt.Fprintf(h, "|%s", marshalStrings(t.UsesTypes))
fmt.Fprintf(h, "|%s|%s|%s|%s|%s", t.FilePath, t.Examples, t.Notes, t.Documentation, t.Code)
fmt.Fprintf(h, "|%s|%s|%s", t.SourceRepo, t.SourceLicense, t.SourceFile)
return fmt.Sprintf("%x", h.Sum(nil))
}
// ComputeAppHash computes a deterministic hash of all content fields of an App.
func ComputeAppHash(a *App) string {
h := sha256.New()
fmt.Fprintf(h, "%s|%s|%s|%s|%s",
a.ID, a.Name, a.Lang, a.Domain, a.Description)
fmt.Fprintf(h, "|%s", marshalStrings(a.Tags))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesFunctions))
fmt.Fprintf(h, "|%s", marshalStrings(a.UsesTypes))
fmt.Fprintf(h, "|%s|%s|%s|%s|%s", a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath)
return fmt.Sprintf("%x", h.Sum(nil))
}
// LoadTimestamps reads existing id → {created_at, updated_at, content_hash} from all tables.
// Called before Purge so we can preserve dates across reindexing.
func (db *DB) LoadTimestamps() (funcs, types, apps map[string]timestampRecord, err error) {
funcs, err = loadTable(db, "functions")
if err != nil {
return
}
types, err = loadTable(db, "types")
if err != nil {
return
}
apps, err = loadTable(db, "apps")
return
}
func loadTable(db *DB, table string) (map[string]timestampRecord, error) {
rows, err := db.conn.Query(fmt.Sprintf("SELECT id, created_at, updated_at, content_hash FROM %s", table))
if err != nil {
return nil, err
}
defer rows.Close()
m := make(map[string]timestampRecord)
for rows.Next() {
var id, ca, ua, ch string
if err := rows.Scan(&id, &ca, &ua, &ch); err != nil {
return nil, err
}
rec := timestampRecord{ContentHash: ch}
rec.CreatedAt, _ = time.Parse(time.RFC3339, ca)
rec.UpdatedAt, _ = time.Parse(time.RFC3339, ua)
m[id] = rec
}
return m, rows.Err()
}
+38 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
// IndexResult holds stats from an indexing run.
@@ -24,6 +25,12 @@ type IndexResult struct {
// Scans functions/ and types/ at the root level, plus any language-specific
// directories (e.g. python/functions/, python/types/).
func Index(db *DB, root string) (*IndexResult, error) {
// Load existing timestamps before purging so we can preserve created_at
oldFuncs, oldTypes, oldApps, err := db.LoadTimestamps()
if err != nil {
return nil, fmt.Errorf("loading timestamps: %w", err)
}
if err := db.Purge(); err != nil {
return nil, fmt.Errorf("purging database: %w", err)
}
@@ -109,12 +116,16 @@ func Index(db *DB, root string) (*IndexResult, error) {
knownTypes[t.ID] = true
}
// Pass 2: validate and insert
now := time.Now().UTC()
// Pass 2: validate, assign timestamps via hash comparison, and insert
for _, t := range types {
if verr := ValidateType(t, knownTypes); verr != nil {
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
continue
}
t.ContentHash = ComputeTypeHash(t)
applyTimestamps(&t.CreatedAt, &t.UpdatedAt, t.ContentHash, oldTypes[t.ID], now)
if err := db.InsertType(t); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", t.ID, err))
continue
@@ -127,6 +138,8 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
continue
}
f.ContentHash = ComputeFunctionHash(f)
applyTimestamps(&f.CreatedAt, &f.UpdatedAt, f.ContentHash, oldFuncs[f.ID], now)
if err := db.InsertFunction(f); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", f.ID, err))
continue
@@ -139,6 +152,8 @@ func Index(db *DB, root string) (*IndexResult, error) {
result.ValidationErrors = append(result.ValidationErrors, verr.Error())
continue
}
a.ContentHash = ComputeAppHash(a)
applyTimestamps(&a.CreatedAt, &a.UpdatedAt, a.ContentHash, oldApps[a.ID], now)
if err := db.InsertApp(a); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", a.ID, err))
continue
@@ -149,6 +164,28 @@ func Index(db *DB, root string) (*IndexResult, error) {
return result, nil
}
// applyTimestamps sets created_at and updated_at based on whether the entry
// existed before and whether its content changed.
// - New entry (no old record): both set to now
// - Unchanged (hash matches): both preserved from old record
// - Changed (hash differs): created_at preserved, updated_at set to now
func applyTimestamps(createdAt, updatedAt *time.Time, newHash string, old timestampRecord, now time.Time) {
if old.CreatedAt.IsZero() {
// New entry
*createdAt = now
*updatedAt = now
return
}
// Existing entry — always preserve created_at
*createdAt = old.CreatedAt
if old.ContentHash == newHash {
// No changes — preserve updated_at too
*updatedAt = old.UpdatedAt
} else {
*updatedAt = now
}
}
// walkMD walks a directory recursively and calls fn for each .md file found.
func walkMD(dir string, fn func(path string)) {
if _, err := os.Stat(dir); err != nil {
+4
View File
@@ -0,0 +1,4 @@
-- Add content_hash to detect changes across reindexing and preserve timestamps.
ALTER TABLE functions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE apps ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,8 @@
-- Source attribution for functions extracted from external repositories.
ALTER TABLE functions ADD COLUMN source_repo TEXT NOT NULL DEFAULT '';
ALTER TABLE functions ADD COLUMN source_license TEXT NOT NULL DEFAULT '';
ALTER TABLE functions ADD COLUMN source_file TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN source_repo TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN source_license TEXT NOT NULL DEFAULT '';
ALTER TABLE types ADD COLUMN source_file TEXT NOT NULL DEFAULT '';
+9
View File
@@ -54,6 +54,10 @@ type Function struct {
Tests []string `json:"tests"`
TestFilePath string `json:"test_file_path"`
FilePath string `json:"file_path"`
ContentHash string `json:"content_hash"`
SourceRepo string `json:"source_repo"`
SourceLicense string `json:"source_license"`
SourceFile string `json:"source_file"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -90,6 +94,10 @@ type Type struct {
Documentation string `json:"documentation"`
Code string `json:"code"`
FilePath string `json:"file_path"`
ContentHash string `json:"content_hash"`
SourceRepo string `json:"source_repo"`
SourceLicense string `json:"source_license"`
SourceFile string `json:"source_file"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -109,6 +117,7 @@ type App struct {
Documentation string `json:"documentation"`
Notes string `json:"notes"`
DirPath string `json:"dir_path"`
ContentHash string `json:"content_hash"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+14
View File
@@ -32,6 +32,11 @@ type rawFunction struct {
TestFilePath string `yaml:"test_file_path"`
FilePath string `yaml:"file_path"`
// Source attribution
SourceRepo string `yaml:"source_repo"`
SourceLicense string `yaml:"source_license"`
SourceFile string `yaml:"source_file"`
// Component fields
Props []PropDef `yaml:"props"`
Emits []string `yaml:"emits"`
@@ -52,6 +57,9 @@ type rawType struct {
Tags []string `yaml:"tags"`
UsesTypes []string `yaml:"uses_types"`
FilePath string `yaml:"file_path"`
SourceRepo string `yaml:"source_repo"`
SourceLicense string `yaml:"source_license"`
SourceFile string `yaml:"source_file"`
}
// rawApp mirrors the YAML frontmatter of an app .md file.
@@ -146,6 +154,9 @@ func ParseFunctionMD(path string, root string) (*Function, error) {
HasState: raw.HasState,
Framework: raw.Framework,
Variant: raw.Variant,
SourceRepo: raw.SourceRepo,
SourceLicense: raw.SourceLicense,
SourceFile: raw.SourceFile,
}
if root != "" && raw.FilePath != "" {
@@ -196,6 +207,9 @@ func ParseTypeMD(path string, root string) (*Type, error) {
Description: raw.Description,
Tags: raw.Tags,
UsesTypes: raw.UsesTypes,
SourceRepo: raw.SourceRepo,
SourceLicense: raw.SourceLicense,
SourceFile: raw.SourceFile,
Examples: sections.example,
Notes: sections.notes,
Documentation: sections.documentation,
+36 -23
View File
@@ -57,11 +57,13 @@ func unmarshalProps(s string) []PropDef {
// InsertFunction inserts or replaces a function entry.
func (db *DB) InsertFunction(f *Function) error {
now := time.Now().UTC().Format(time.RFC3339)
now := time.Now().UTC()
if f.CreatedAt.IsZero() {
f.CreatedAt = time.Now().UTC()
f.CreatedAt = now
}
if f.UpdatedAt.IsZero() {
f.UpdatedAt = now
}
f.UpdatedAt = time.Now().UTC()
if f.ID == "" {
f.ID = GenerateID(f.Name, f.Lang, f.Domain)
@@ -81,34 +83,39 @@ func (db *DB) InsertFunction(f *Function) error {
id, name, kind, lang, domain, version, purity, signature,
description, tags, uses_functions, uses_types, returns,
returns_optional, error_type, imports, example, tested,
tests, test_file_path, file_path, created_at, updated_at,
tests, test_file_path, file_path, content_hash, created_at, updated_at,
props, emits, has_state, framework, variant,
notes, documentation, code
notes, documentation, code,
source_repo, source_license, source_file
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?
)`,
f.ID, f.Name, string(f.Kind), f.Lang, f.Domain, f.Version, string(f.Purity), f.Signature,
f.Description, marshalStrings(f.Tags), marshalStrings(f.UsesFunctions), marshalStrings(f.UsesTypes), marshalStrings(f.Returns),
f.ReturnsOptional, f.ErrorType, marshalStrings(f.Imports), f.Example, f.Tested,
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.CreatedAt.Format(time.RFC3339), now,
marshalStrings(f.Tests), f.TestFilePath, f.FilePath, f.ContentHash, f.CreatedAt.Format(time.RFC3339), f.UpdatedAt.Format(time.RFC3339),
marshalProps(f.Props), marshalStrings(f.Emits), hasState, f.Framework, marshalStrings(f.Variant),
f.Notes, f.Documentation, f.Code,
f.SourceRepo, f.SourceLicense, f.SourceFile,
)
return err
}
// InsertType inserts or replaces a type entry.
func (db *DB) InsertType(t *Type) error {
now := time.Now().UTC().Format(time.RFC3339)
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
t.UpdatedAt = time.Now().UTC()
if t.ID == "" {
t.ID = GenerateID(t.Name, t.Lang, t.Domain)
@@ -118,13 +125,15 @@ func (db *DB) InsertType(t *Type) error {
INSERT OR REPLACE INTO types (
id, name, lang, domain, version, algebraic,
definition, description, tags, uses_types,
file_path, created_at, updated_at,
examples, notes, documentation, code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
file_path, content_hash, created_at, updated_at,
examples, notes, documentation, code,
source_repo, source_license, source_file
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Lang, t.Domain, t.Version, string(t.Algebraic),
t.Definition, t.Description, marshalStrings(t.Tags), marshalStrings(t.UsesTypes),
t.FilePath, t.CreatedAt.Format(time.RFC3339), now,
t.FilePath, t.ContentHash, t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
t.Examples, t.Notes, t.Documentation, t.Code,
t.SourceRepo, t.SourceLicense, t.SourceFile,
)
return err
}
@@ -263,11 +272,13 @@ func (db *DB) DeleteType(id string) error {
// InsertApp inserts or replaces an app entry.
func (db *DB) InsertApp(a *App) error {
now := time.Now().UTC().Format(time.RFC3339)
now := time.Now().UTC()
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now().UTC()
a.CreatedAt = now
}
if a.UpdatedAt.IsZero() {
a.UpdatedAt = now
}
a.UpdatedAt = time.Now().UTC()
if a.ID == "" {
a.ID = GenerateID(a.Name, a.Lang, a.Domain)
@@ -277,11 +288,11 @@ func (db *DB) InsertApp(a *App) error {
INSERT OR REPLACE INTO apps (
id, name, lang, domain, description, tags,
uses_functions, uses_types, framework, entry_point,
documentation, notes, dir_path, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentation, notes, dir_path, content_hash, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags),
marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint,
a.Documentation, a.Notes, a.DirPath, a.CreatedAt.Format(time.RFC3339), now,
a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339),
)
return err
}
@@ -347,7 +358,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error)
err := rows.Scan(
&a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON,
&usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint,
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt,
&a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash,
)
if err != nil {
return nil, fmt.Errorf("scanning app: %w", err)
@@ -391,7 +402,8 @@ func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Functio
&f.ReturnsOptional, &f.ErrorType, &importsJSON, &f.Example, &f.Tested,
&testsJSON, &f.TestFilePath, &f.FilePath, &createdAt, &updatedAt,
&propsJSON, &emitsJSON, &hasState, &f.Framework, &variantJSON,
&f.Notes, &f.Documentation, &f.Code,
&f.Notes, &f.Documentation, &f.Code, &f.ContentHash,
&f.SourceRepo, &f.SourceLicense, &f.SourceFile,
)
if err != nil {
return nil, fmt.Errorf("scanning function: %w", err)
@@ -430,7 +442,8 @@ func scanTypes(rows interface{ Next() bool; Scan(...any) error }) ([]Type, error
&t.ID, &t.Name, &t.Lang, &t.Domain, &t.Version, &t.Algebraic,
&t.Definition, &t.Description, &tagsJSON, &usesTypJSON,
&t.FilePath, &createdAt, &updatedAt,
&t.Examples, &t.Notes, &t.Documentation, &t.Code,
&t.Examples, &t.Notes, &t.Documentation, &t.Code, &t.ContentHash,
&t.SourceRepo, &t.SourceLicense, &t.SourceFile,
)
if err != nil {
return nil, fmt.Errorf("scanning type: %w", err)
+20
View File
@@ -0,0 +1,20 @@
# Manifest de repositorios externos para extraccion de funciones.
# Cada entrada registra un repo clonado en sources/ y las funciones extraidas.
#
# Formato:
# - repo: https://github.com/user/project
# license: MIT
# cloned_dir: project # nombre del directorio en sources/
# extracted: # funciones ya extraidas (el agente las registra)
# - id: func_name_go_core
# source_file: pkg/utils.go # path relativo dentro del repo original
# date: 2026-03-29
#
# Workflow:
# 1. Clonar repo en sources/: git clone <url> sources/<nombre>
# 2. Invocar agente extractor para analizar y proponer funciones
# 3. El agente copia, adapta, crea .go + .md con atribucion
# 4. fn index para registrar en registry.db
# 5. Actualizar este manifest con las funciones extraidas
repos: []