# functions

id: http_error_response_go_infra
name: http_error_response
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPErrorResponse(w http.ResponseWriter, err HTTPError)
tags: ["http","error","response","json","server","infra"]
uses_functions: []
uses_types: ["HTTPError_go_infra"]
returns: []
error_type: error_go_core
imports: ["encoding/json","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_error_response.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3d30346fa16669186935eb7136997fec72b853ddfd9234635894ed21137b432f
description:
  Escribe un HTTPError como JSON en el ResponseWriter. Usa err.Status como status code HTTP y serializa los campos Code y Message en el body.

example:
  ```go
  func createUser(w http.ResponseWriter, r *http.Request) {
      var user User
      if err := HTTPParseBody(r, &user, 1<<20); err != nil {
          HTTPErrorResponse(w, HTTPError{
              Status:  http.StatusBadRequest,
              Code:    "invalid_body",
              Message: err.Error(),
          })
          return
      }
      // ...
  }
  ```

tests:
  ["escribe el status code del HTTPError","setea Content-Type application/json","serializa Code y Message en el body JSON"]

notes:
  Conveniente para APIs JSON que siempre retornan errores en formato estructurado. Se puede componer con HTTPParseBody: si el parse falla, llamar HTTPErrorResponse con 400. El body JSON tiene la forma `{"Status":400,"Code":"...","Message":"..."}`.

code:
  package infra

  import (
  	"encoding/json"
  	"net/http"
  )

  // HTTPErrorResponse escribe un HTTPError como JSON en w.
  // Setea Content-Type a application/json y usa err.Status como status code.
  func HTTPErrorResponse(w http.ResponseWriter, err HTTPError) {
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(err.Status)
  	json.NewEncoder(w).Encode(err)
  }

params_schema:
  {"params":[{"name":"w","desc":"ResponseWriter donde se escribe la respuesta de error"},{"name":"err","desc":"HTTPError con Status (codigo HTTP), Code (identificador maquina) y Message (texto legible)"}],"output":"escribe la respuesta de error JSON directamente en w, sin valor de retorno"}


id: http_get_json_go_infra
name: http_get_json
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","get","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","fmt","io","net/http","time"]
tests: ["httptest.Server con respuesta JSON","Status 404 → error","Timeout → error","Headers custom"]
test_file_path: functions/infra/http_get_json_test.go
file_path: functions/infra/http_get_json.go
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: cf0392ffb51629fb2e1ae74d742314641fd5348ce9b2d598c1b91492dc82a545
signature:
  func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error)

description:
  GET request que espera JSON. Agrega Accept: application/json automaticamente. Retorna error con status code si >= 400. Siempre cierra body con defer.

example:
  ```go
  result, err := HttpGetJSON(
      "https://api.example.com/users",
      map[string]string{"X-Api-Key": "secret"},
      10*time.Second,
  )
  if err != nil {
      return nil, err
  }
  fmt.Println(result["total"])
  ```

notes:
  Solo usa stdlib (net/http, encoding/json). El timeout se configura en el http.Client. El error incluye los primeros 200 bytes del body para facilitar debugging. Los headers custom se fusionan con Accept: application/json (custom tiene precedencia).

code:
  package infra

  import (
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // HttpGetJSON realiza un GET request a url y parsea la respuesta como JSON.
  // Agrega Accept: application/json automaticamente. Retorna error si status >= 400
  // incluyendo el status code y los primeros 200 bytes del body.
  func HttpGetJSON(url string, headers map[string]string, timeout time.Duration) (map[string]any, error) {
  	client := &http.Client{Timeout: timeout}

  	req, err := http.NewRequest(http.MethodGet, url, nil)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: build request: %w", err)
  	}

  	req.Header.Set("Accept", "application/json")
  	for k, v := range headers {
  		req.Header.Set(k, v)
  	}

  	resp, err := client.Do(req)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: %w", err)
  	}
  	defer resp.Body.Close()

  	body, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return nil, fmt.Errorf("http_get_json: read body: %w", err)
  	}

  	if resp.StatusCode >= 400 {
  		preview := body
  		if len(preview) > 200 {
  			preview = preview[:200]
  		}
  		shortURL := url
  		if len(shortURL) > 100 {
  			shortURL = shortURL[:100]
  		}
  		return nil, fmt.Errorf("http_get_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
  	}

  	var result map[string]any
  	if err := json.Unmarshal(body, &result); err != nil {
  		return nil, fmt.Errorf("http_get_json: parse JSON: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint HTTP a consultar"},{"name":"headers","desc":"mapa de headers custom a incluir en la solicitud"},{"name":"timeout","desc":"duracion maxima para completar la solicitud"}],"output":"mapa con la estructura JSON decodificada del response"}


id: http_json_response_go_infra
name: http_json_response
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPJSONResponse(w http.ResponseWriter, status int, data any)
tags: ["http","json","response","server","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_json_response.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 7fac420df1de9e296ab912acb1caa86e1f497e8ab81f9efa6b384b11de1f5f4a
description:
  Escribe data como JSON en el ResponseWriter con el status code dado. Setea Content-Type: application/json automaticamente.

example:
  ```go
  func getUser(w http.ResponseWriter, r *http.Request) {
      user := map[string]string{"id": "1", "name": "Lucas"}
      HTTPJSONResponse(w, http.StatusOK, user)
  }
  ```

tests:
  ["escribe status code correcto","setea Content-Type application/json","serializa datos correctamente a JSON"]

notes:
  Usa json.NewEncoder para streaming directo al ResponseWriter sin buffer intermedio. El header Content-Type debe setearse antes de WriteHeader. Errores de serializacion se propagan parcialmente al body — asegurarse de pasar tipos serializable a JSON.

code:
  package infra

  import (
  	"encoding/json"
  	"net/http"
  )

  // HTTPJSONResponse escribe data como JSON en w con el status code dado.
  // Setea Content-Type a application/json antes de escribir el body.
  func HTTPJSONResponse(w http.ResponseWriter, status int, data any) {
  	w.Header().Set("Content-Type", "application/json")
  	w.WriteHeader(status)
  	json.NewEncoder(w).Encode(data)
  }

params_schema:
  {"params":[{"name":"w","desc":"ResponseWriter donde se escribe la respuesta HTTP"},{"name":"status","desc":"codigo de status HTTP (ej: 200, 201, 400, 500)"},{"name":"data","desc":"cualquier valor serializable a JSON que se escribe como body de la respuesta"}],"output":"escribe la respuesta JSON directamente en w, sin valor de retorno"}


id: http_logger_middleware_go_infra
name: http_logger_middleware
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPLoggerMiddleware(logger io.Writer) Middleware
tags: ["http","logger","middleware","logging","server","infra"]
uses_functions: []
uses_types: ["Middleware_go_infra"]
returns: ["Middleware_go_infra"]
error_type: error_go_core
imports: ["fmt","io","net/http","time"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_logger_middleware.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3a94457c67b36da3cc14ad1b7decede23ebaaa4b6ff4fa4f4ae01e0f82a2d023
description:
  Retorna un Middleware que loguea metodo, path, status code y duracion de cada request HTTP. El formato es: METHOD /path STATUS DURACIONms

example:
  ```go
  logger := HTTPLoggerMiddleware(os.Stderr)
  chain := HTTPMiddlewareChain(logger)
  // Cada request imprime: GET /api/users 200 5ms
  http.ListenAndServe(":8080", chain(mux))
  ```

tests:
  ["loguea metodo y path del request","loguea el status code de la respuesta","loguea la duracion en milisegundos"]

notes:
  Captura el status code con un responseWriter envolvente que intercepta WriteHeader. Si el handler no llama WriteHeader explicitamente, el status por defecto es 200. El logger recibe cualquier io.Writer — util para tests usando bytes.Buffer o para escribir a archivos de log.

code:
  package infra

  import (
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // responseWriter captura el status code escrito al ResponseWriter.
  type responseWriter struct {
  	http.ResponseWriter
  	status int
  }

  func (rw *responseWriter) WriteHeader(status int) {
  	rw.status = status
  	rw.ResponseWriter.WriteHeader(status)
  }

  // HTTPLoggerMiddleware retorna un Middleware que loguea metodo, path, status y duracion de cada request.
  // El formato de cada linea es: METHOD /path STATUS DURACIONms
  func HTTPLoggerMiddleware(logger io.Writer) Middleware {
  	return func(next http.Handler) http.Handler {
  		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  			start := time.Now()
  			rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
  			next.ServeHTTP(rw, r)
  			duration := time.Since(start)
  			fmt.Fprintf(logger, "%s %s %d %dms\n",
  				r.Method, r.URL.Path, rw.status, duration.Milliseconds())
  		})
  	}
  }

params_schema:
  {"params":[{"name":"logger","desc":"io.Writer donde se escriben las lineas de log (ej: os.Stderr, un archivo, bytes.Buffer)"}],"output":"Middleware que intercepta cada request, llama al siguiente handler, y loguea los detalles al terminar"}


id: http_middleware_chain_go_infra
name: http_middleware_chain
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func HTTPMiddlewareChain(middlewares ...Middleware) Middleware
tags: ["http","middleware","chain","compose","server","infra"]
uses_functions: []
uses_types: ["Middleware_go_infra"]
returns: ["Middleware_go_infra"]
imports: ["net/http"]
test_file_path: functions/infra/http_middleware_chain_test.go
file_path: functions/infra/http_middleware_chain.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: c5cb47cbc1cfc1b2029287811719064faeb314065a51fbf0c689e1ab1f6e654a
description:
  Compone N middlewares en uno solo. El primer middleware de la lista es el mas externo (se ejecuta primero en el request y ultimo en el response).

example:
  ```go
  chain := HTTPMiddlewareChain(
      HTTPLoggerMiddleware(os.Stderr),
      HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST"}),
  )
  handler := chain(myHandler)
  http.ListenAndServe(":8080", handler)
  ```

tests:
  ["sin middlewares pasa al handler final","un middleware se aplica correctamente","dos middlewares se aplican en orden izquierda-derecha"]

notes:
  Funcion pura — solo combina funciones, sin I/O ni estado. La composicion es de derecha a izquierda internamente para que el primer middleware quede en la capa mas externa. Con cero middlewares retorna un Middleware identidad que no modifica el handler.

code:
  package infra

  import "net/http"

  // HTTPMiddlewareChain compone N middlewares en uno solo aplicandolos de izquierda a derecha.
  // El primer middleware de la lista es el mas externo (se ejecuta primero).
  // Si no se pasan middlewares retorna un Middleware que no modifica el handler.
  func HTTPMiddlewareChain(middlewares ...Middleware) Middleware {
  	return func(final http.Handler) http.Handler {
  		// Aplicar en orden inverso para que el primero quede mas externo
  		h := final
  		for i := len(middlewares) - 1; i >= 0; i-- {
  			h = middlewares[i](h)
  		}
  		return h
  	}
  }

params_schema:
  {"params":[{"name":"middlewares","desc":"lista variadic de middlewares a componer, aplicados de izquierda a derecha (el primero es el mas externo)"}],"output":"un Middleware unico que aplica todos los middlewares en orden"}


id: http_parse_body_go_infra
name: http_parse_body
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error
tags: ["http","json","parse","body","request","server","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["encoding/json","fmt","io","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_parse_body.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 9447951d2ea815392cb2c7c9d6cc7c87df5516883cb416d4f3e544261b276788
description:
  Decodifica el body JSON de un http.Request en dst, limitando la lectura a maxBytes. Rechaza campos desconocidos y multiples objetos JSON.

example:
  ```go
  func createUser(w http.ResponseWriter, r *http.Request) {
      var input struct {
          Name  string `json:"name"`
          Email string `json:"email"`
      }
      if err := HTTPParseBody(r, &input, 1<<20); err != nil {
          HTTPErrorResponse(w, HTTPError{Status: 400, Code: "invalid_body", Message: err.Error()})
          return
      }
      // usar input.Name, input.Email
  }
  ```

tests:
  ["decodifica JSON valido correctamente","retorna error si el body supera maxBytes","retorna error si el JSON es invalido","retorna error si hay campos desconocidos"]

notes:
  Usa http.MaxBytesReader para limitar el body antes de decodificar. DisallowUnknownFields rechaza campos JSON no mapeados en dst — util para validacion estricta de contratos API. Consume el body restante con io.Discard despues del decode.

code:
  package infra

  import (
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  )

  // HTTPParseBody decodifica el body JSON de r en dst, limitando la lectura a maxBytes.
  // Retorna error si el body excede maxBytes, si el JSON es invalido, o si hay error de lectura.
  func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error {
  	r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
  	dec := json.NewDecoder(r.Body)
  	dec.DisallowUnknownFields()
  	if err := dec.Decode(dst); err != nil {
  		return fmt.Errorf("parse body: %w", err)
  	}
  	// Verificar que no queda mas data despues del primer objeto JSON
  	if dec.More() {
  		return fmt.Errorf("parse body: request body must contain a single JSON object")
  	}
  	// Consumir el resto para liberar el body
  	io.Copy(io.Discard, r.Body)
  	return nil
  }

params_schema:
  {"params":[{"name":"r","desc":"http.Request cuyo body se va a decodificar"},{"name":"dst","desc":"puntero al valor donde se decodifica el JSON (ej: \u0026myStruct)"},{"name":"maxBytes","desc":"limite maximo de bytes del body (ej: 1\u003c\u003c20 para 1 MB)"}],"output":"nil si el parse fue exitoso, error si el body excede maxBytes, si el JSON es invalido, si hay campos desconocidos, o si hay multiples objetos"}


id: http_post_json_go_infra
name: http_post_json
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","post","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["bytes","encoding/json","fmt","io","net/http","time"]
tests: ["httptest.Server recibe body correcto","Status 201 → exito","Status 500 → error con body parcial"]
test_file_path: functions/infra/http_post_json_test.go
file_path: functions/infra/http_post_json.go
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5e9198ac0d3036b2edf85c00b4ef56d0d9c2049bfc97937e17601d403a6425c4
signature:
  func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error)

description:
  POST request con body JSON serializado con json.Marshal. Agrega Content-Type: application/json y Accept: application/json. Retorna error con status code si >= 400.

example:
  ```go
  result, err := HttpPostJSON(
      "https://api.example.com/users",
      map[string]any{"name": "Alice", "role": "admin"},
      map[string]string{"X-Api-Key": "secret"},
      10*time.Second,
  )
  if err != nil {
      return nil, err
  }
  fmt.Println(result["id"])
  ```

notes:
  Solo usa stdlib. El body acepta `any` y se serializa con json.Marshal. Headers custom se fusionan con Content-Type y Accept por defecto. El error incluye los primeros 200 bytes del body de respuesta.

code:
  package infra

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  	"time"
  )

  // HttpPostJSON realiza un POST request con body JSON y parsea la respuesta como JSON.
  // Agrega Content-Type: application/json y Accept: application/json automaticamente.
  // Retorna error si status >= 400 incluyendo status code y los primeros 200 bytes del body.
  func HttpPostJSON(url string, body any, headers map[string]string, timeout time.Duration) (map[string]any, error) {
  	data, err := json.Marshal(body)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: marshal body: %w", err)
  	}

  	client := &http.Client{Timeout: timeout}

  	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: build request: %w", err)
  	}

  	req.Header.Set("Content-Type", "application/json")
  	req.Header.Set("Accept", "application/json")
  	for k, v := range headers {
  		req.Header.Set(k, v)
  	}

  	resp, err := client.Do(req)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: %w", err)
  	}
  	defer resp.Body.Close()

  	respBody, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return nil, fmt.Errorf("http_post_json: read body: %w", err)
  	}

  	if resp.StatusCode >= 400 {
  		preview := respBody
  		if len(preview) > 200 {
  			preview = preview[:200]
  		}
  		shortURL := url
  		if len(shortURL) > 100 {
  			shortURL = shortURL[:100]
  		}
  		return nil, fmt.Errorf("http_post_json: HTTP %d at %q — %s", resp.StatusCode, shortURL, preview)
  	}

  	var result map[string]any
  	if err := json.Unmarshal(respBody, &result); err != nil {
  		return nil, fmt.Errorf("http_post_json: parse JSON: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"url","desc":"URL del endpoint HTTP"},{"name":"body","desc":"datos a serializar como JSON en el cuerpo de la solicitud"},{"name":"headers","desc":"mapa de headers custom a fusionar con los headers por defecto"},{"name":"timeout","desc":"duracion maxima para completar la solicitud"}],"output":"mapa con la estructura JSON decodificada del response"}


id: http_router_go_infra
name: http_router
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPRouter(routes []Route) *http.ServeMux
tags: ["http","router","mux","server","routes","infra"]
uses_functions: []
uses_types: ["Route_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","net/http"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_router.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: fac187450b01a85f9cfe9996b59979b669d9b3319ae07ef57c9d2b66002c96c2
description:
  Crea un http.ServeMux y registra las rutas dadas usando la sintaxis de Go 1.22+ (METHOD /path). Retorna el mux listo para usar con http.ListenAndServe.

example:
  ```go
  mux := HTTPRouter([]Route{
      {Method: "GET",  Path: "/api/health", Handler: http.HandlerFunc(healthHandler)},
      {Method: "POST", Path: "/api/users",  Handler: http.HandlerFunc(createUserHandler)},
      {Method: "GET",  Path: "/api/users/{id}", Handler: http.HandlerFunc(getUserHandler)},
  })
  http.ListenAndServe(":8080", mux)
  ```

tests:
  ["registra una ruta y responde correctamente","registra multiples rutas en el mismo mux","ruta no registrada retorna 404"]

notes:
  Usa la sintaxis "METHOD /path" de Go 1.22+ para registrar rutas con metodo especifico. Soporta parametros de path como {id} accesibles via r.PathValue("id"). Sin rutas registradas retorna un mux vacio que responde 404 a todo.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  )

  // HTTPRouter crea un http.ServeMux y registra las rutas dadas.
  // Usa la sintaxis de Go 1.22+: "METHOD /path" como patron del mux.
  func HTTPRouter(routes []Route) *http.ServeMux {
  	mux := http.NewServeMux()
  	for _, route := range routes {
  		pattern := fmt.Sprintf("%s %s", route.Method, route.Path)
  		mux.Handle(pattern, route.Handler)
  	}
  	return mux
  }

params_schema:
  {"params":[{"name":"routes","desc":"slice de Route con Method, Path y Handler a registrar en el mux"}],"output":"http.ServeMux configurado con todas las rutas"}


id: http_serve_go_infra
name: http_serve
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func HTTPServe(addr string, handler http.Handler, ctx context.Context) error
tags: ["http","server","serve","graceful","shutdown","context","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","fmt","net/http","time"]
tests: ["servidor arranca y responde peticiones","graceful shutdown al cancelar contexto"]
test_file_path: functions/infra/http_server_test.go
file_path: functions/infra/http_serve.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: e529c06b21bd291290afbcfe47171e8ca704f7e8acfb9732eef3964499323579
description:
  Arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela. Espera hasta 30 segundos para que las conexiones activas cierren.

example:
  ```go
  ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  defer cancel()

  mux := HTTPRouter(routes)
  chain := HTTPMiddlewareChain(HTTPLoggerMiddleware(os.Stderr))
  if err := HTTPServe(":8080", chain(mux), ctx); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Arranca ListenAndServe en una goroutine y espera en un select: si el servidor falla devuelve el error inmediatamente, si ctx se cancela hace Shutdown con timeout de 30s. El patron estandar es usar signal.NotifyContext con SIGINT/SIGTERM para el graceful shutdown.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"net/http"
  	"time"
  )

  // HTTPServe arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela.
  // Espera hasta 30 segundos para que las conexiones activas cierren antes de forzar el shutdown.
  func HTTPServe(addr string, handler http.Handler, ctx context.Context) error {
  	srv := &http.Server{
  		Addr:    addr,
  		Handler: handler,
  	}

  	errCh := make(chan error, 1)
  	go func() {
  		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  			errCh <- fmt.Errorf("http serve %s: %w", addr, err)
  		}
  		close(errCh)
  	}()

  	select {
  	case err := <-errCh:
  		return err
  	case <-ctx.Done():
  		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  		defer cancel()
  		if err := srv.Shutdown(shutdownCtx); err != nil {
  			return fmt.Errorf("http shutdown %s: %w", addr, err)
  		}
  		return nil
  	}
  }

params_schema:
  {"params":[{"name":"addr","desc":"direccion donde escuchar (ej: \":8080\", \"0.0.0.0:9090\")"},{"name":"handler","desc":"http.Handler que procesa las peticiones (normalmente un *http.ServeMux con middlewares)"},{"name":"ctx","desc":"contexto que al cancelarse dispara el graceful shutdown del servidor"}],"output":"nil si el servidor se detuvo correctamente por cancelacion de contexto, error si fallo al arrancar o al hacer shutdown"}


id: job_cleanup_go_infra
name: job_cleanup
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobCleanup(q *JobQueue, olderThan time.Duration) (int64, error)
tags: ["job","queue","cleanup","delete","maintenance","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
tests: ["job_cleanup_removes_old_terminal_jobs","job_cleanup_keeps_recent_jobs"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_cleanup.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 5217b304dc1aac29ac89e34db68cd8329830aeba828707001d4396f19d7efcc6
description:
  Elimina jobs en estados terminales (completed, failed, dead) cuyo created_at sea mas antiguo que olderThan. Retorna el numero de filas eliminadas. Util para mantener la tabla compacta en apps de larga duracion.

example:
  ```go
  // Limpiar jobs terminados hace mas de 7 dias
  n, err := JobCleanup(q, 7*24*time.Hour)
  fmt.Printf("eliminados: %d jobs\n", n)
  ```

notes:
  Solo elimina jobs en estados terminales: completed, failed, dead. Los jobs pending y running nunca se eliminan. El cutoff se calcula en UTC. Llamar periodicamente con CronTicker u otro scheduler para mantener la tabla compacta.

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobCleanup deletes jobs in terminal states (completed, failed, dead) that are
  // older than olderThan. Returns the number of rows deleted.
  // This is useful for keeping the jobs table small in long-running applications.
  func JobCleanup(q *JobQueue, olderThan time.Duration) (int64, error) {
  	if q == nil {
  		return 0, fmt.Errorf("job_cleanup: queue must not be nil")
  	}

  	cutoff := time.Now().UTC().Add(-olderThan).Format(time.RFC3339)

  	query := fmt.Sprintf(`
  DELETE FROM %s
  WHERE status IN ('completed', 'failed', 'dead')
    AND created_at < ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, cutoff)
  	if err != nil {
  		return 0, fmt.Errorf("job_cleanup: delete: %w", err)
  	}
  	n, err := res.RowsAffected()
  	if err != nil {
  		return 0, fmt.Errorf("job_cleanup: rows affected: %w", err)
  	}
  	return n, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"olderThan","desc":"duracion maxima de retension; jobs mas viejos que esto se eliminan (ej: 24*time.Hour)"}],"output":"numero de filas eliminadas"}


id: job_complete_go_infra
name: job_complete
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobComplete(q *JobQueue, jobID string, result string) error
tags: ["job","queue","complete","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
example:
  ```go
  err := JobComplete(q, job.ID, `{"rows_processed":42}`)
  ```

tests: ["complete_fail_transitions"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_complete.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 76c75d629216ac8e46dc5b3adfb999afc6132864b87f101cc301d44c810b06d6
description:
  Marca un job como completado, setea completed_at y almacena el resultado opcional. result puede ser vacio si no hay payload de resultado.

notes:
  Setea `status='completed'`, `completed_at=now`, y `result` (NULL si vacio). Retorna error si el job no existe (0 rows affected).

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobComplete marks a job as completed and stores the optional result JSON string.
  // result may be empty ("") to indicate no result payload.
  func JobComplete(q *JobQueue, jobID string, result string) error {
  	if q == nil {
  		return fmt.Errorf("job_complete: queue must not be nil")
  	}
  	if jobID == "" {
  		return fmt.Errorf("job_complete: jobID must not be empty")
  	}

  	now := time.Now().UTC().Format(time.RFC3339)
  	var resultPtr *string
  	if result != "" {
  		resultPtr = &result
  	}

  	query := fmt.Sprintf(`
  UPDATE %s
  SET status = 'completed', completed_at = ?, result = ?
  WHERE id = ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, now, resultPtr, jobID)
  	if err != nil {
  		return fmt.Errorf("job_complete: update: %w", err)
  	}
  	n, _ := res.RowsAffected()
  	if n == 0 {
  		return fmt.Errorf("job_complete: job %q not found", jobID)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobID","desc":"UUID del job a marcar como completado"},{"name":"result","desc":"JSON string con el resultado del job; puede ser vacio"}],"output":"error si el job no existe o falla el UPDATE"}


id: job_dequeue_go_infra
name: job_dequeue
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobDequeue(q *JobQueue, jobTypes []string) (*Job, error)
tags: ["job","queue","dequeue","atomic","transaction","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra","job_go_infra","job_status_go_infra"]
returns: ["job_go_infra"]
error_type: error_go_core
imports: ["database/sql","errors","fmt","strings","time"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_dequeue.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 5e547fa60cdd0396880dd28b7d6930cb4ba34dc468eb6c9ab4b1c059652f8da3
description:
  Extrae atomicamente el siguiente job disponible usando SELECT+UPDATE en una transaccion exclusiva. Retorna nil, nil si la cola esta vacia o no hay jobs que cumplan el filtro. jobTypes limita los tipos dequeued; slice vacio significa todos.

example:
  ```go
  // Desencolar cualquier tipo
  job, err := JobDequeue(q, nil)
  if job == nil {
      // cola vacia
  }

  // Filtrar por tipo
  job, err = JobDequeue(q, []string{"send_email", "send_sms"})
  ```

tests:
  ["enqueue_dequeue_atomicidad","dequeue_empty_queue_returns_nil","dequeue_priority_order","dequeue_jobtype_filter","dequeue_scheduled_in_future_waits"]

notes:
  Usa `db.Begin()` + `tx.Rollback()` (deferred) + `tx.Commit()`. El SELECT filtra por `status='pending' AND scheduled_at <= now` y ordena por `priority DESC, scheduled_at ASC`. El UPDATE atomico cambia el status a 'running' y setea `started_at`. Safe para multiples workers concurrentes — SQLite serializa la transaccion.

code:
  package infra

  import (
  	"database/sql"
  	"errors"
  	"fmt"
  	"strings"
  	"time"
  )

  // JobDequeue atomically claims the next available job by running a
  // SELECT + UPDATE inside an EXCLUSIVE transaction. Returns nil, nil when the
  // queue is empty (or no job matches the jobTypes filter).
  // jobTypes restricts which job types are dequeued; an empty slice means all types.
  func JobDequeue(q *JobQueue, jobTypes []string) (*Job, error) {
  	if q == nil {
  		return nil, fmt.Errorf("job_dequeue: queue must not be nil")
  	}

  	tx, err := q.DB.Begin()
  	if err != nil {
  		return nil, fmt.Errorf("job_dequeue: begin tx: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Build the SELECT query.
  	now := time.Now().UTC().Format(time.RFC3339)

  	var selectQ string
  	var args []any

  	if len(jobTypes) > 0 {
  		placeholders := make([]string, len(jobTypes))
  		for i, t := range jobTypes {
  			placeholders[i] = "?"
  			args = append(args, t)
  		}
  		selectQ = fmt.Sprintf(`
  SELECT id, type, payload, status, priority, attempts, max_attempts,
         scheduled_at, started_at, completed_at, result, error, created_at
  FROM %s
  WHERE status = 'pending'
    AND scheduled_at <= ?
    AND type IN (%s)
  ORDER BY priority DESC, scheduled_at ASC
  LIMIT 1
  `, q.TableName, strings.Join(placeholders, ","))
  		args = append(args, now)
  		// rearrange: now goes before jobTypes in the query
  		newArgs := []any{now}
  		newArgs = append(newArgs, args[:len(args)-1]...)
  		args = newArgs
  	} else {
  		selectQ = fmt.Sprintf(`
  SELECT id, type, payload, status, priority, attempts, max_attempts,
         scheduled_at, started_at, completed_at, result, error, created_at
  FROM %s
  WHERE status = 'pending'
    AND scheduled_at <= ?
  ORDER BY priority DESC, scheduled_at ASC
  LIMIT 1
  `, q.TableName)
  		args = []any{now}
  	}

  	row := tx.QueryRow(selectQ, args...)

  	var job Job
  	var scheduledAt string
  	var startedAt, completedAt sql.NullString
  	var result, jobError sql.NullString
  	var createdAt string

  	err = row.Scan(
  		&job.ID, &job.Type, &job.Payload, (*string)(&job.Status),
  		&job.Priority, &job.Attempts, &job.MaxAttempts,
  		&scheduledAt, &startedAt, &completedAt,
  		&result, &jobError, &createdAt,
  	)
  	if errors.Is(err, sql.ErrNoRows) {
  		return nil, nil
  	}
  	if err != nil {
  		return nil, fmt.Errorf("job_dequeue: scan: %w", err)
  	}

  	// Mark as running.
  	updateQ := fmt.Sprintf(`
  UPDATE %s SET status = 'running', started_at = ? WHERE id = ?
  `, q.TableName)
  	if _, err := tx.Exec(updateQ, now, job.ID); err != nil {
  		return nil, fmt.Errorf("job_dequeue: update status: %w", err)
  	}

  	if err := tx.Commit(); err != nil {
  		return nil, fmt.Errorf("job_dequeue: commit: %w", err)
  	}

  	// Hydrate optional fields.
  	if t, err := time.Parse(time.RFC3339, scheduledAt); err == nil {
  		job.ScheduledAt = t
  	}
  	if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
  		job.CreatedAt = t
  	}
  	if startedAt.Valid {
  		if t, err := time.Parse(time.RFC3339, startedAt.String); err == nil {
  			job.StartedAt = &t
  		}
  	}
  	if completedAt.Valid {
  		if t, err := time.Parse(time.RFC3339, completedAt.String); err == nil {
  			job.CompletedAt = &t
  		}
  	}
  	if result.Valid {
  		job.Result = &result.String
  	}
  	if jobError.Valid {
  		job.Error = &jobError.String
  	}
  	job.Status = JobStatusRunning

  	return &job, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobTypes","desc":"lista de tipos a desencolar; slice vacio o nil = todos los tipos"}],"output":"puntero a Job con status=running, o nil si la cola esta vacia"}


id: job_enqueue_go_infra
name: job_enqueue
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobEnqueue(q *JobQueue, jobType string, payload string, opts ...EnqueueOption) (string, error)
tags: ["job","queue","enqueue","insert","async","background","infra","sqlite"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time","github.com/google/uuid"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_enqueue.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: cdd389c17e29028906786fc12f3fd5fb2c73bb3ec5a0a6bf708eddede5c5f614
description:
  Inserta un nuevo job en la cola con UUID generado, tipo, payload JSON y opciones. Retorna el UUID del job. Opciones: WithPriority, WithScheduledAt, WithMaxAttempts.

example:
  ```go
  id, err := JobEnqueue(q, "send_email", `{"to":"user@example.com"}`,
      WithPriority(5),
      WithMaxAttempts(5),
  )
  ```

tests:
  ["enqueue_dequeue_atomicidad","dequeue_priority_order","dequeue_jobtype_filter","dequeue_scheduled_in_future_waits"]

notes:
  Usa `github.com/google/uuid` para generar el ID. El payload por defecto es "{}". El campo `scheduled_at` por defecto es `time.Now().UTC()`. La prioridad mas alta se procesa primero (ORDER BY priority DESC).

code:
  package infra

  import (
  	"fmt"
  	"time"

  	"github.com/google/uuid"
  )

  // JobEnqueue inserts a new job into the queue and returns its UUID.
  // jobType identifies the kind of work (e.g. "send_email", "resize_image").
  // payload is a JSON string with the job data (defaults to "{}" if empty).
  // Options: WithPriority, WithScheduledAt, WithMaxAttempts.
  func JobEnqueue(q *JobQueue, jobType string, payload string, opts ...EnqueueOption) (string, error) {
  	if q == nil {
  		return "", fmt.Errorf("job_enqueue: queue must not be nil")
  	}
  	if jobType == "" {
  		return "", fmt.Errorf("job_enqueue: jobType must not be empty")
  	}
  	if payload == "" {
  		payload = "{}"
  	}

  	cfg := &enqueueConfig{
  		priority:    0,
  		scheduledAt: time.Now().UTC(),
  		maxAttempts: 3,
  	}
  	for _, o := range opts {
  		o(cfg)
  	}

  	id := uuid.New().String()
  	scheduledAt := cfg.scheduledAt.Format(time.RFC3339)

  	q2 := fmt.Sprintf(`
  INSERT INTO %s (id, type, payload, status, priority, max_attempts, scheduled_at)
  VALUES (?, ?, ?, 'pending', ?, ?, ?)
  `, q.TableName)

  	_, err := q.DB.Exec(q2, id, jobType, payload, cfg.priority, cfg.maxAttempts, scheduledAt)
  	if err != nil {
  		return "", fmt.Errorf("job_enqueue: insert: %w", err)
  	}
  	return id, nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobType","desc":"identificador del tipo de trabajo (ej: 'send_email', 'resize_image')"},{"name":"payload","desc":"JSON string con los datos del job; si vacio se usa '{}'"},{"name":"opts","desc":"opciones: WithPriority(n), WithScheduledAt(t), WithMaxAttempts(n)"}],"output":"UUID del job insertado como string"}


id: job_fail_go_infra
name: job_fail
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobFail(q *JobQueue, jobID string, errMsg string) error
tags: ["job","queue","fail","retry","dead","sqlite","async","background","infra"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","time"]
example:
  ```go
  if err := handler(job); err != nil {
  _ = JobFail(q, job.ID, err.Error())
  }
  ```

tests: ["fail_increments_attempts","fail_transitions_to_dead"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_fail.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 169c323d230c5439f93997b8940166a95365fd3cd3a4c4dcb9f4f277080bc008
description:
  Incrementa el contador de intentos y transiciona el job a 'failed' (reintentable) o 'dead' (sin mas intentos) segun si attempts >= max_attempts. Almacena el mensaje de error.

notes:
  El UPDATE es atomico: incrementa attempts y decide el nuevo status en un solo SQL con CASE WHEN. Si `(attempts + 1) >= max_attempts` → status='dead', sino → status='failed'. Los jobs failed pueden ser reintentados manualmente reseteando su status a 'pending'. Los jobs dead no se procesan automaticamente.

code:
  package infra

  import (
  	"fmt"
  	"time"
  )

  // JobFail increments the attempt counter and transitions the job to:
  //   - "failed"  when attempts < max_attempts (eligible for retry)
  //   - "dead"    when attempts >= max_attempts (no more retries)
  //
  // errMsg is stored in the error column for debugging.
  func JobFail(q *JobQueue, jobID string, errMsg string) error {
  	if q == nil {
  		return fmt.Errorf("job_fail: queue must not be nil")
  	}
  	if jobID == "" {
  		return fmt.Errorf("job_fail: jobID must not be empty")
  	}

  	now := time.Now().UTC().Format(time.RFC3339)

  	// Atomically increment attempts and decide status.
  	query := fmt.Sprintf(`
  UPDATE %s
  SET
      attempts    = attempts + 1,
      status      = CASE WHEN (attempts + 1) >= max_attempts THEN 'dead' ELSE 'failed' END,
      error       = ?,
      completed_at = ?
  WHERE id = ?
  `, q.TableName)

  	res, err := q.DB.Exec(query, errMsg, now, jobID)
  	if err != nil {
  		return fmt.Errorf("job_fail: update: %w", err)
  	}
  	n, _ := res.RowsAffected()
  	if n == 0 {
  		return fmt.Errorf("job_fail: job %q not found", jobID)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"jobID","desc":"UUID del job que fallo"},{"name":"errMsg","desc":"mensaje de error para almacenar en la columna error"}],"output":"error si el job no existe o falla el UPDATE"}


id: job_queue_create_go_infra
name: job_queue_create
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobQueueCreate(db *sql.DB, tableName string) (*JobQueue, error)
tags: ["job","queue","sqlite","async","background","infra","create","schema"]
uses_functions: []
uses_types: ["job_queue_go_infra"]
returns: ["job_queue_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt"]
tests: ["test_queue_create","test_queue_create_default_table_name","test_queue_create_nil_db"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_queue_create.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 0c9b43d6ce5ae084894a37ae42ed06c1ffd59f283d2a9c0279b9f9ca788800e7
description:
  Crea (o verifica) la tabla de jobs con indices en SQLite, activa WAL mode, y retorna un *JobQueue listo para usar. tableName puede ser cualquier identificador SQLite valido (default: 'jobs').

example:
  ```go
  db, _ := sql.Open("sqlite3", "jobs.db?_journal_mode=WAL")
  q, err := JobQueueCreate(db, "jobs")
  if err != nil {
      log.Fatal(err)
  }
  // q esta listo para JobEnqueue, JobDequeue, etc.
  ```

notes:
  Activa WAL mode en la DB. Usa `CREATE TABLE IF NOT EXISTS` y `CREATE INDEX IF NOT EXISTS` — es seguro llamarlo multiples veces. Indices creados: `idx_{table}_dequeue` (filtrado partial por status='pending'), `idx_{table}_status`, `idx_{table}_type`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  )

  // JobQueueCreate creates (or verifies) the jobs table and required indices in
  // the given SQLite database, activates WAL mode, and returns a ready *JobQueue.
  // tableName is typically "jobs" but can be any valid SQLite identifier.
  //
  // Schema created:
  //
  //	CREATE TABLE IF NOT EXISTS jobs (
  //	    id TEXT PRIMARY KEY,
  //	    type TEXT NOT NULL,
  //	    payload TEXT NOT NULL DEFAULT '{}',
  //	    status TEXT NOT NULL DEFAULT 'pending',
  //	    priority INTEGER NOT NULL DEFAULT 0,
  //	    attempts INTEGER NOT NULL DEFAULT 0,
  //	    max_attempts INTEGER NOT NULL DEFAULT 3,
  //	    scheduled_at TEXT NOT NULL,
  //	    started_at TEXT,
  //	    completed_at TEXT,
  //	    result TEXT,
  //	    error TEXT,
  //	    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
  //	);
  func JobQueueCreate(db *sql.DB, tableName string) (*JobQueue, error) {
  	if db == nil {
  		return nil, fmt.Errorf("job_queue_create: db must not be nil")
  	}
  	if tableName == "" {
  		tableName = "jobs"
  	}

  	// Enable WAL mode for concurrent read/write access.
  	if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
  		return nil, fmt.Errorf("job_queue_create: enable WAL: %w", err)
  	}

  	schema := fmt.Sprintf(`
  CREATE TABLE IF NOT EXISTS %s (
      id TEXT PRIMARY KEY,
      type TEXT NOT NULL,
      payload TEXT NOT NULL DEFAULT '{}',
      status TEXT NOT NULL DEFAULT 'pending',
      priority INTEGER NOT NULL DEFAULT 0,
      attempts INTEGER NOT NULL DEFAULT 0,
      max_attempts INTEGER NOT NULL DEFAULT 3,
      scheduled_at TEXT NOT NULL,
      started_at TEXT,
      completed_at TEXT,
      result TEXT,
      error TEXT,
      created_at TEXT NOT NULL DEFAULT (strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'))
  );
  CREATE INDEX IF NOT EXISTS idx_%s_dequeue ON %s (status, priority DESC, scheduled_at ASC) WHERE status = 'pending';
  CREATE INDEX IF NOT EXISTS idx_%s_status ON %s (status);
  CREATE INDEX IF NOT EXISTS idx_%s_type ON %s (type);
  `, tableName,
  		tableName, tableName,
  		tableName, tableName,
  		tableName, tableName,
  	)

  	if _, err := db.Exec(schema); err != nil {
  		return nil, fmt.Errorf("job_queue_create: create schema: %w", err)
  	}

  	return &JobQueue{DB: db, TableName: tableName}, nil
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion SQLite abierta (puede ser :memory: o file-based)"},{"name":"tableName","desc":"nombre de la tabla de jobs; si esta vacio se usa 'jobs'"}],"output":"puntero a JobQueue con DB y TableName configurados, listo para enqueue/dequeue"}


id: job_status_summary_go_infra
name: job_status_summary
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func JobStatusSummary(counts map[string]int) string
tags: ["job","queue","status","summary","format","pure","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","strings"]
tests: ["job_status_summary_format","job_status_summary_empty_map"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_status.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 402fefd5b1b34f346282b6c4f50d08b2ac34a9abc4aa84e20679b391423e0b59
description:
  Formatea un mapa de status→conteo en un resumen legible. Siempre incluye los cinco estados canonicos en orden fijo: pending, running, completed, failed, dead.

example:
  ```go
  counts := map[string]int{"pending": 5, "running": 2, "completed": 10}
  summary := JobStatusSummary(counts)
  // "pending: 5, running: 2, completed: 10, failed: 0, dead: 0"
  ```

notes:
  Funcion pura. Los estados no presentes en el mapa se tratan como 0. El orden de salida es siempre fijo: pending, running, completed, failed, dead.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // JobStatus formats a map of status → count into a human-readable summary string.
  // Example output: "pending: 5, running: 2, completed: 10, failed: 1, dead: 0"
  // The output always includes all five canonical statuses in a fixed order.
  // This is a pure function — no I/O, no state.
  func JobStatusSummary(counts map[string]int) string {
  	statuses := []string{"pending", "running", "completed", "failed", "dead"}
  	parts := make([]string, 0, len(statuses))
  	for _, s := range statuses {
  		parts = append(parts, fmt.Sprintf("%s: %d", s, counts[s]))
  	}
  	return strings.Join(parts, ", ")
  }

params_schema:
  {"params":[{"name":"counts","desc":"mapa de nombre de status (pending/running/completed/failed/dead) a numero de jobs en ese estado"}],"output":"string formateado 'pending: N, running: N, completed: N, failed: N, dead: N'"}


id: job_worker_go_infra
name: job_worker
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func JobWorker(ctx context.Context, q *JobQueue, handler JobHandler, opts ...WorkerOption) error
tags: ["job","queue","worker","goroutine","poll","async","background","infra","concurrency"]
uses_functions: ["job_dequeue_go_infra","job_complete_go_infra","job_fail_go_infra"]
uses_types: ["job_queue_go_infra","job_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","fmt","time"]
tests: ["worker_graceful_shutdown","worker_processes_jobs"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_worker.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 46c119f41e3a3c5b663c742e05a93ffd6b92616e859ec4d07561b865f613d15d
description:
  Ejecuta un poll loop bloqueante que desencola y procesa jobs hasta que el context sea cancelado. El handler determina complete vs fail. Opciones: WithPollInterval (default 1s), WithJobTypes.

example:
  ```go
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  go JobWorker(ctx, q, func(job Job) error {
      fmt.Println("processing", job.ID, job.Type)
      return nil // complete
  }, WithPollInterval(500*time.Millisecond), WithJobTypes("send_email"))
  ```

notes:
  Funcion bloqueante — ejecutar en una goroutine. Retorna `ctx.Err()` cuando el context se cancela (graceful shutdown). Los errores transitorios de dequeue (DB) generan un sleep y retry. El handler tiene la responsabilidad completa del procesamiento; JobWorker se encarga de complete/fail.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"time"
  )

  // JobWorker runs a poll loop in the calling goroutine, dequeuing and processing
  // jobs until ctx is cancelled. handler is called for each job; its return value
  // drives complete vs fail logic. The worker sleeps pollInterval between empty
  // dequeue attempts.
  //
  // Options: WithPollInterval (default 1s), WithJobTypes (default all types).
  //
  // This function blocks until ctx is done. Run it in a goroutine:
  //
  //	go JobWorker(ctx, q, handler, WithPollInterval(2*time.Second))
  func JobWorker(ctx context.Context, q *JobQueue, handler JobHandler, opts ...WorkerOption) error {
  	if q == nil {
  		return fmt.Errorf("job_worker: queue must not be nil")
  	}
  	if handler == nil {
  		return fmt.Errorf("job_worker: handler must not be nil")
  	}

  	cfg := &workerConfig{
  		pollInterval: time.Second,
  	}
  	for _, o := range opts {
  		o(cfg)
  	}

  	for {
  		select {
  		case <-ctx.Done():
  			return ctx.Err()
  		default:
  		}

  		job, err := JobDequeue(q, cfg.jobTypes)
  		if err != nil {
  			// Transient DB error — wait and retry.
  			select {
  			case <-ctx.Done():
  				return ctx.Err()
  			case <-time.After(cfg.pollInterval):
  			}
  			continue
  		}

  		if job == nil {
  			// Queue empty — wait before polling again.
  			select {
  			case <-ctx.Done():
  				return ctx.Err()
  			case <-time.After(cfg.pollInterval):
  			}
  			continue
  		}

  		// Process the job.
  		handlerErr := handler(*job)
  		if handlerErr != nil {
  			_ = JobFail(q, job.ID, handlerErr.Error())
  		} else {
  			_ = JobComplete(q, job.ID, "")
  		}
  	}
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context que al cancelarse detiene el worker limpiamente (graceful shutdown)"},{"name":"q","desc":"cola de jobs creada con JobQueueCreate"},{"name":"handler","desc":"funcion que procesa un job; retorno nil = completado, error = fallo"},{"name":"opts","desc":"opciones: WithPollInterval(d), WithJobTypes(types...)"}],"output":"ctx.Err() cuando el context se cancela, o error de configuracion"}


id: job_worker_pool_go_infra
name: job_worker_pool
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
uses_functions: ["job_worker_go_infra"]
uses_types: ["job_queue_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","fmt","golang.org/x/sync/errgroup"]
tests: ["concurrency_multiples_goroutines_dequeue"]
test_file_path: functions/infra/job_queue_test.go
file_path: functions/infra/job_worker_pool.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: f8dac7ee6c165dcc5c54c688b93270786db778d3a7b70e57f7171438a18a3f14
signature:
  func JobWorkerPool(ctx context.Context, q *JobQueue, n int, handler JobHandler, opts ...WorkerOption) error

description:
  Lanza n workers concurrentes que comparten la misma cola y handler. Usa errgroup para concurrencia estructurada y soporta graceful shutdown via context. Bloquea hasta que todos los workers terminen.

tags:
  ["job","queue","worker","pool","concurrency","errgroup","async","background","infra","graceful-shutdown"]

example:
  ```go
  ctx, cancel := context.WithCancel(context.Background())
  defer cancel()

  // 5 workers procesando emails en paralelo
  err := JobWorkerPool(ctx, q, 5, func(job Job) error {
      return sendEmail(job)
  }, WithPollInterval(100*time.Millisecond))
  ```

notes:
  Usa `golang.org/x/sync/errgroup`. Si un worker retorna un error no-context, `errgroup` cancela el context derivado y todos los demas workers se detienen. El graceful shutdown ocurre al cancelar el context original.

code:
  package infra

  import (
  	"context"
  	"fmt"

  	"golang.org/x/sync/errgroup"
  )

  // JobWorkerPool starts n workers concurrently, all sharing the same queue and
  // handler. It uses errgroup for structured concurrency and supports graceful
  // shutdown via ctx cancellation.
  //
  // The function blocks until all workers exit. It returns the first non-context
  // error encountered, or ctx.Err() if all workers stopped due to cancellation.
  //
  // Options: WithPollInterval, WithJobTypes (applied to every worker).
  func JobWorkerPool(ctx context.Context, q *JobQueue, n int, handler JobHandler, opts ...WorkerOption) error {
  	if q == nil {
  		return fmt.Errorf("job_worker_pool: queue must not be nil")
  	}
  	if handler == nil {
  		return fmt.Errorf("job_worker_pool: handler must not be nil")
  	}
  	if n <= 0 {
  		return fmt.Errorf("job_worker_pool: n must be > 0, got %d", n)
  	}

  	g, gctx := errgroup.WithContext(ctx)
  	for i := 0; i < n; i++ {
  		g.Go(func() error {
  			return JobWorker(gctx, q, handler, opts...)
  		})
  	}
  	return g.Wait()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context que al cancelarse detiene todos los workers; si uno falla, los demas tambien se detienen"},{"name":"q","desc":"cola de jobs compartida por todos los workers"},{"name":"n","desc":"numero de workers concurrentes (debe ser \u003e 0)"},{"name":"handler","desc":"funcion de procesamiento compartida por todos los workers"},{"name":"opts","desc":"opciones aplicadas a cada worker: WithPollInterval, WithJobTypes"}],"output":"primer error no-context de cualquier worker, o ctx.Err() si todos terminaron por cancelacion"}


id: metabase_auth_go_infra
name: metabase_auth
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error)
tags: ["metabase","auth","session","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: ["MetabaseClient_go_infra"]
error_type: error_go_core
imports: ["bytes","encoding/json","fmt","io","net/http"]
tests: []
file_path: functions/infra/metabase_auth.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b40c6ef04255880f42b4ba5509e3557898a991b1f2fe0bda543226baa886e73b
description:
  Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias (configurable con MAX_SESSION_AGE en Metabase). Endpoint: POST /api/session.

example:
  ```go
  // Autenticar con credenciales
  client, err := MetabaseAuth("http://localhost:3000", "admin@example.com", "password123")
  if err != nil {
      log.Fatal(err)
  }
  // client.Token contiene el session token

  // Alternativa: usar API key directamente
  client := MetabaseNewClient("http://localhost:3000", "mb_api_key_xxxxx")
  ```

notes:
  Dos formas de obtener un MetabaseClient:
  - `MetabaseAuth`: login con email/password, obtiene session token via POST /api/session. Token expira en 14 dias por defecto.
  - `MetabaseNewClient`: usa una API key creada en el admin UI. No expira. Recomendado para automatizacion.

  El token se envia como header `X-Metabase-Session` en todas las llamadas subsiguientes.

  ### Para un LLM que use estas funciones

  1. Primero obtener un client con `MetabaseAuth()` o `MetabaseNewClient()`
  2. Pasar el client a todas las funciones CRUD (usuarios, cards, dashboards)
  3. Si recibes error 401, el token expiro — re-autenticar
  4. Rate limiting: Metabase limita intentos de login fallidos

code:
  package infra

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  )

  // MetabaseAuth autentica con email y password contra una instancia Metabase.
  // Retorna un MetabaseClient con el session token listo para usar.
  // baseURL es la URL base sin trailing slash (ej: "http://localhost:3000").
  func MetabaseAuth(baseURL, email, password string) (MetabaseClient, error) {
  	payload, _ := json.Marshal(map[string]string{
  		"username": email,
  		"password": password,
  	})

  	resp, err := http.Post(baseURL+"/api/session", "application/json", bytes.NewReader(payload))
  	if err != nil {
  		return MetabaseClient{}, fmt.Errorf("metabase auth: %w", err)
  	}
  	defer resp.Body.Close()

  	body, err := io.ReadAll(resp.Body)
  	if err != nil {
  		return MetabaseClient{}, fmt.Errorf("read auth response: %w", err)
  	}

  	if resp.StatusCode != 200 {
  		return MetabaseClient{}, fmt.Errorf("metabase auth: status %d: %s", resp.StatusCode, string(body))
  	}

  	var result struct {
  		ID string `json:"id"`
  	}
  	if err := json.Unmarshal(body, &result); err != nil {
  		return MetabaseClient{}, fmt.Errorf("parse auth response: %w", err)
  	}

  	return MetabaseClient{BaseURL: baseURL, Token: result.ID}, nil
  }

  // MetabaseNewClient crea un MetabaseClient usando una API key en lugar de session token.
  // Las API keys se crean en Settings > Authentication > API Keys del admin de Metabase.
  func MetabaseNewClient(baseURL, apiKey string) MetabaseClient {
  	return MetabaseClient{BaseURL: baseURL, Token: apiKey}
  }

params_schema:
  {"params":[{"name":"baseURL","desc":"URL base de Metabase (ej: http://localhost:3000)"},{"name":"email","desc":"email de la cuenta para autenticarse"},{"name":"password","desc":"contraseña de la cuenta"}],"output":"cliente MetabaseClient con session token valido por 14 dias"}


id: metabase_create_card_go_infra
name: metabase_create_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
description: Crea una nueva card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card.
tags: ["metabase","card","question","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 89423d94986dae277ea7ba6a867840b734c837329272c2bd51704d93a16aae30
signature:
  func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error)

example:
  ```go
  // Crear pregunta con SQL nativo
  card, err := MetabaseCreateCard(client, "Revenue by Month", map[string]any{
      "database": 1,
      "type":     "native",
      "native": map[string]any{
          "query": "SELECT date_trunc('month', created_at) as month, SUM(total) as revenue FROM orders GROUP BY 1 ORDER BY 1",
      },
  }, "line", 5, "Monthly revenue trend")

  // Crear pregunta con MBQL (structured query)
  card, err := MetabaseCreateCard(client, "Order Count", map[string]any{
      "database": 1,
      "type":     "query",
      "query": map[string]any{
          "source-table": 4,
          "aggregation":  []any{[]any{"count"}},
      },
  }, "scalar", 0, "Total number of orders")
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | name | string | si | Nombre de la pregunta |
  | datasetQuery | map[string]any | si | Query. Ver estructura abajo |
  | display | string | si | Tipo de visualizacion |
  | collectionID | int | no | ID de coleccion. 0 = root collection |
  | description | string | no | Descripcion. Vacio = sin descripcion |

  ### Estructura de datasetQuery

  **SQL nativo:**
  ```json
  {
    "database": <database_id>,
    "type": "native",
    "native": {"query": "SELECT ..."}
  }
  ```

  **MBQL (structured):**
  ```json
  {
    "database": <database_id>,
    "type": "query",
    "query": {
      "source-table": <table_id>,
      "aggregation": [["count"]],
      "breakout": [["field", <field_id>, {"temporal-unit": "month"}]],
      "filter": ["=", ["field", <field_id>, null], "value"]
    }
  }
  ```

  ### Valores de display

  table, bar, line, pie, scalar, area, row, combo, funnel, map, scatter, waterfall, progress, gauge

code:
  package infra

  import "fmt"

  // MetabaseCreateCard crea una nueva card/pregunta en Metabase.
  // name: nombre de la pregunta (obligatorio).
  // datasetQuery: query de la card (obligatorio). Estructura:
  //
  //	SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
  //	MBQL:       {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
  //
  // display: tipo de visualizacion ("table", "bar", "line", "pie", "scalar", etc.).
  // collectionID: ID de la coleccion/carpeta (0 = root).
  // description: descripcion opcional (vacio = sin descripcion).
  func MetabaseCreateCard(client MetabaseClient, name string, datasetQuery map[string]any, display string, collectionID int, description string) (map[string]any, error) {
  	body := map[string]any{
  		"name":                   name,
  		"dataset_query":          datasetQuery,
  		"display":                display,
  		"visualization_settings": map[string]any{},
  	}
  	if collectionID > 0 {
  		body["collection_id"] = collectionID
  	}
  	if description != "" {
  		body["description"] = description
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/card", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create card: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"name","desc":"nombre de la pregunta/card"},{"name":"datasetQuery","desc":"estructura de query SQL nativa o MBQL (structured query)"},{"name":"display","desc":"tipo de visualizacion (table, bar, line, pie, scalar, etc.)"},{"name":"collectionID","desc":"ID de la coleccion destino (0 = root collection)"},{"name":"description","desc":"descripcion de la pregunta o vacio"}],"output":"mapa con los detalles de la card creada incluyendo su ID"}


id: metabase_create_dashboard_go_infra
name: metabase_create_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 060ba8d26bbdd49212d555ad3a4526910623d42bc07108ef55462cf11742e916
signature:
  func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error)

description:
  Crea un nuevo dashboard vacio en Metabase. Para agregar cards usar MetabaseUpdateDashboard con el campo dashcards. Endpoint: POST /api/dashboard.

example:
  ```go
  // Crear dashboard vacio
  dashboard, err := MetabaseCreateDashboard(client, "Sales Overview", "KPIs de ventas", 5)
  if err != nil {
      log.Fatal(err)
  }
  dashboardID := int(dashboard["id"].(float64))

  // Luego agregar cards con MetabaseUpdateDashboard
  MetabaseUpdateDashboard(client, dashboardID, map[string]any{
      "dashcards": []map[string]any{
          {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
      },
  })
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | name | string | si | Nombre del dashboard |
  | description | string | no | Descripcion. Vacio = sin descripcion |
  | collectionID | int | no | Coleccion destino. 0 = root |

  El dashboard se crea vacio. Para agregar cards, usar MetabaseUpdateDashboard con el array dashcards.
  Retorna el objeto dashboard creado.

code:
  package infra

  import "fmt"

  // MetabaseCreateDashboard crea un nuevo dashboard en Metabase.
  // name: nombre del dashboard (obligatorio).
  // description: descripcion opcional (vacio = sin descripcion).
  // collectionID: ID de la coleccion/carpeta (0 = root).
  func MetabaseCreateDashboard(client MetabaseClient, name, description string, collectionID int) (map[string]any, error) {
  	body := map[string]any{
  		"name": name,
  	}
  	if description != "" {
  		body["description"] = description
  	}
  	if collectionID > 0 {
  		body["collection_id"] = collectionID
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dashboard", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create dashboard: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"name","desc":"nombre del dashboard"},{"name":"description","desc":"descripcion del dashboard o vacio"},{"name":"collectionID","desc":"ID de la coleccion destino (0 = root collection)"}],"output":"mapa con los detalles del dashboard creado incluyendo su ID"}


id: metabase_create_user_go_infra
name: metabase_create_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","create","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_create_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e4ee437830f0521ebb49d3d5b8dd03bbc4d681a93c15e983e4d35a8bc9ed7849
signature:
  func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error)

description:
  Crea un nuevo usuario en Metabase. Si no se provee password, Metabase envia email de invitacion. Requiere permisos de superusuario. Endpoint: POST /api/user.

example:
  ```go
  // Crear usuario con password
  user, err := MetabaseCreateUser(client, "John", "Doe", "john@example.com", "securePass123", nil)

  // Crear usuario sin password (envia invitacion por email)
  user, err := MetabaseCreateUser(client, "Jane", "Smith", "jane@example.com", "", []int{1, 3})
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado con permisos admin |
  | firstName | string | si | Nombre del usuario |
  | lastName | string | si | Apellido del usuario |
  | email | string | si | Email unico del usuario |
  | password | string | no | Password. Vacio = Metabase envia invitacion |
  | groupIDs | []int | no | IDs de grupos. nil = solo grupo default |

  El email debe ser unico. Si ya existe, retorna error 400.
  Retorna el objeto usuario creado como map (mismos campos que MetabaseGetUser).

code:
  package infra

  import "fmt"

  // MetabaseCreateUser crea un nuevo usuario en Metabase.
  // firstName, lastName y email son obligatorios.
  // password es opcional: si esta vacio, Metabase envia email de invitacion.
  // groupIDs es opcional: IDs de grupos a asignar (nil = solo grupo default).
  func MetabaseCreateUser(client MetabaseClient, firstName, lastName, email, password string, groupIDs []int) (map[string]any, error) {
  	body := map[string]any{
  		"first_name": firstName,
  		"last_name":  lastName,
  		"email":      email,
  	}
  	if password != "" {
  		body["password"] = password
  	}
  	if len(groupIDs) > 0 {
  		body["group_ids"] = groupIDs
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/user", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase create user: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"firstName","desc":"nombre del usuario"},{"name":"lastName","desc":"apellido del usuario"},{"name":"email","desc":"email unico del usuario"},{"name":"password","desc":"contraseña o vacio para enviar invitacion por email"},{"name":"groupIDs","desc":"slice de IDs de grupos a los que agregar el usuario (nil = grupo default)"}],"output":"mapa con los detalles del usuario creado incluyendo su ID"}


id: metabase_deactivate_user_go_infra
name: metabase_deactivate_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeactivateUser(client MetabaseClient, userID int) error
tags: ["metabase","user","delete","deactivate","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_deactivate_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 578d28a8c502794f881145383f6bc346aad1551bdac22386673d849869cb8eb8
description:
  Desactiva (soft-delete) un usuario en Metabase. El usuario no se elimina permanentemente, solo se marca como inactivo. Para reactivar, usar PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id.

example:
  ```go
  err := MetabaseDeactivateUser(client, 5)
  if err != nil {
      log.Fatal(err)
  }
  // Usuario 5 ahora esta inactivo
  // Para ver desactivados: MetabaseListUsers(client, "deactivated", "", 0, 0)
  ```

notes:
  Es un soft-delete: el usuario se desactiva pero no se borra. Se puede reactivar con PUT /api/user/:id/reactivate.

  Para listar usuarios desactivados, usar `MetabaseListUsers` con status "deactivated".

  Requiere permisos de superusuario. Error 403 si no eres admin.

code:
  package infra

  import "fmt"

  // MetabaseDeactivateUser desactiva (soft-delete) un usuario en Metabase.
  // El usuario no se elimina permanentemente, solo se marca como inactivo.
  // Requiere permisos de superusuario.
  func MetabaseDeactivateUser(client MetabaseClient, userID int) error {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase deactivate user %d: %w", userID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"userID","desc":"ID del usuario a desactivar"}],"output":"nil si la desactivacion fue exitosa, error en caso contrario"}


id: metabase_delete_card_go_infra
name: metabase_delete_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeleteCard(client MetabaseClient, cardID int) error
tags: ["metabase","card","question","delete","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_delete_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: dff069f36609ad63008f1d96656a722776b15018a150bc70c4c36946486f8f8d
description:
  Elimina permanentemente una card/pregunta de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateCard con archived:true. Endpoint: DELETE /api/card/:id.

example:
  ```go
  // Eliminar permanentemente
  err := MetabaseDeleteCard(client, 42)

  // Preferir soft-delete cuando sea posible:
  // MetabaseUpdateCard(client, 42, map[string]any{"archived": true})
  ```

notes:
  **ATENCION**: Esta operacion es irreversible. La card se elimina permanentemente.

  Para un borrado seguro, preferir archivar con `MetabaseUpdateCard(client, cardID, map[string]any{"archived": true})` que permite recuperar la card despues.

code:
  package infra

  import "fmt"

  // MetabaseDeleteCard elimina permanentemente una card/pregunta de Metabase.
  // Para soft-delete, usar MetabaseUpdateCard con archived: true.
  func MetabaseDeleteCard(client MetabaseClient, cardID int) error {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase delete card %d: %w", cardID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta a eliminar"}],"output":"nil si la eliminacion fue exitosa, error en caso contrario"}


id: metabase_delete_dashboard_go_infra
name: metabase_delete_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error
tags: ["metabase","dashboard","delete","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_delete_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 3324082719bcd85418c7564c9c1caca517b92094dc3b94cbcf106b23aff7783b
description:
  Elimina permanentemente un dashboard de Metabase. Accion irreversible. Para soft-delete usar MetabaseUpdateDashboard con archived:true. Endpoint: DELETE /api/dashboard/:id.

example:
  ```go
  // Eliminar permanentemente
  err := MetabaseDeleteDashboard(client, 1)

  // Preferir soft-delete:
  // MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
  ```

notes:
  **ATENCION**: Esta operacion es irreversible. El dashboard y todas sus dashcards se eliminan permanentemente.

  Para un borrado seguro, preferir archivar con `MetabaseUpdateDashboard(client, dashboardID, map[string]any{"archived": true})`.

code:
  package infra

  import "fmt"

  // MetabaseDeleteDashboard elimina permanentemente un dashboard de Metabase.
  // Para soft-delete, usar MetabaseUpdateDashboard con archived: true.
  func MetabaseDeleteDashboard(client MetabaseClient, dashboardID int) error {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	_, err := metabaseRequest("DELETE", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return fmt.Errorf("metabase delete dashboard %d: %w", dashboardID, err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard a eliminar"}],"output":"nil si la eliminacion fue exitosa, error en caso contrario"}


id: metabase_execute_card_go_infra
name: metabase_execute_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","execute","query","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_execute_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5f3602a369b6a99be696cf9a0f0da08407afba1176800ac0c4f6e63dc30ae298
signature:
  func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error)

description:
  Ejecuta la query de una card/pregunta guardada en Metabase y retorna los resultados. Soporta parametros para queries parametrizadas. Endpoint: POST /api/card/:id/query.

example:
  ```go
  // Ejecutar sin parametros
  result, err := MetabaseExecuteCard(client, 42, nil)
  if err != nil {
      log.Fatal(err)
  }
  data := result["data"].(map[string]any)
  rows := data["rows"].([]any)
  fmt.Printf("Filas: %d\n", len(rows))

  // Ejecutar con parametros
  result, err := MetabaseExecuteCard(client, 42, []map[string]any{
      {
          "type":   "category",
          "target": []any{"variable", []any{"template-tag", "status"}},
          "value":  "active",
      },
  })
  ```

notes:
  ### Estructura de la respuesta

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | status | string | "completed" o "failed" |
  | row_count | float64 | Numero de filas |
  | running_time | float64 | Tiempo de ejecucion en ms |
  | data.columns | []string | Nombres de columnas |
  | data.rows | [][]any | Filas de datos |
  | data.cols | []map | Metadata de columnas (name, base_type, display_name) |
  | data.native_form.query | string | SQL ejecutado |

  ### Parametros para queries parametrizadas

  ```go
  []map[string]any{
      {
          "type":   "category",                                      // tipo del parametro
          "target": []any{"variable", []any{"template-tag", "tag"}}, // referencia al template-tag
          "value":  "valor",                                         // valor a inyectar
      },
  }
  ```

  Limite por defecto: 2000 filas. Para queries ad-hoc sin card, usar MetabaseExecuteQuery.

code:
  package infra

  import "fmt"

  // MetabaseExecuteCard ejecuta la query de una card/pregunta guardada.
  // parameters: parametros de la query (nil si no tiene parametros).
  // Retorna los resultados con columnas y filas.
  func MetabaseExecuteCard(client MetabaseClient, cardID int, parameters []map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d/query", cardID)

  	var body map[string]any
  	if len(parameters) > 0 {
  		body = map[string]any{"parameters": parameters}
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, path, body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase execute card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta guardada a ejecutar"},{"name":"parameters","desc":"slice de maps con parametros para queries parametrizadas (nil si no hay parametros)"}],"output":"mapa con status, row_count, running_time, data.columns, data.rows, data.native_form.query"}


id: metabase_execute_query_go_infra
name: metabase_execute_query
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","query","execute","sql","dataset","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_execute_query.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 714e0f7cf8fa5ec70503e6afc687fea4f4671b8cd307a76a4f8c2226dc3d6449
signature:
  func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error)

description:
  Ejecuta una query SQL ad-hoc contra una database de Metabase sin guardarla como card. Util para consultas rapidas y exploracion. Endpoint: POST /api/dataset.

example:
  ```go
  // Query simple
  result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM users LIMIT 10", 0)
  if err != nil {
      log.Fatal(err)
  }
  data := result["data"].(map[string]any)
  rows := data["rows"].([]any)

  // Query con limite custom
  result, err := MetabaseExecuteQuery(client, 1, "SELECT * FROM orders", 5000)
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | databaseID | int | si | ID de la database en Metabase (obtener con GET /api/database) |
  | sql | string | si | Query SQL a ejecutar |
  | maxResults | int | no | Limite de filas. 0 = default 2000 |

  ### Diferencia con MetabaseExecuteCard

  - `MetabaseExecuteQuery`: query ad-hoc, no se guarda. Usa POST /api/dataset.
  - `MetabaseExecuteCard`: ejecuta una card ya guardada. Usa POST /api/card/:id/query.

  Usar esta funcion para exploracion rapida. Si la query se va a reutilizar, crear una card con MetabaseCreateCard.

  ### Estructura de la respuesta

  Misma estructura que MetabaseExecuteCard:
  - `data.columns`: nombres de columnas
  - `data.rows`: filas de datos
  - `row_count`: numero de filas
  - `running_time`: tiempo en ms
  - `status`: "completed" o "failed"

code:
  package infra

  import "fmt"

  // MetabaseExecuteQuery ejecuta una query ad-hoc (sin guardar como card) en Metabase.
  // databaseID: ID de la base de datos en Metabase.
  // sql: query SQL a ejecutar.
  // maxResults: limite de filas (0 = default 2000 de Metabase).
  func MetabaseExecuteQuery(client MetabaseClient, databaseID int, sql string, maxResults int) (map[string]any, error) {
  	body := map[string]any{
  		"database": databaseID,
  		"type":     "native",
  		"native":   map[string]any{"query": sql},
  	}
  	if maxResults > 0 {
  		body["constraints"] = map[string]any{
  			"max-results":          maxResults,
  			"max-results-bare-rows": maxResults,
  		}
  	}

  	result, err := metabaseRequest("POST", client.BaseURL, client.Token, "/api/dataset", body)
  	if err != nil {
  		return nil, fmt.Errorf("metabase execute query: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"databaseID","desc":"ID de la database en Metabase"},{"name":"sql","desc":"query SQL a ejecutar"},{"name":"maxResults","desc":"limite de filas (0 = default 2000)"}],"output":"mapa con status, row_count, running_time, data.columns, data.rows"}


id: metabase_get_card_go_infra
name: metabase_get_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error)
tags: ["metabase","card","question","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9398fb8fcecc57c982dee969c92b9feea719adc7a1c4e337b849aeb75f43c7c7
description:
  Obtiene los detalles completos de una card/pregunta de Metabase por su ID. Incluye la query, visualizacion y metadata. Endpoint: GET /api/card/:id.

example:
  ```go
  card, err := MetabaseGetCard(client, 42)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(card["name"], card["display"])
  ```

notes:
  Retorna el objeto card completo. Error 404 si no existe.

  ### Campos principales

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID de la card |
  | name | string | Nombre |
  | description | string | Descripcion |
  | display | string | Tipo visualizacion |
  | dataset_query | map | Query (native.query para SQL, query para MBQL) |
  | visualization_settings | map | Config de visualizacion |
  | collection_id | float64 | Coleccion contenedora |
  | database_id | float64 | Database asociada |
  | archived | bool | Archivada |
  | creator | map | Objeto del usuario creador |
  | created_at | string | Fecha creacion |
  | updated_at | string | Fecha actualizacion |

code:
  package infra

  import "fmt"

  // MetabaseGetCard obtiene una card/pregunta de Metabase por su ID.
  func MetabaseGetCard(client MetabaseClient, cardID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta"}],"output":"mapa con detalles completos de la card (nombre, query, visualizacion, etc.)"}


id: metabase_get_dashboard_go_infra
name: metabase_get_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error)
tags: ["metabase","dashboard","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8e43345b2af0f1a4300ad479e4d5fac28187f6eb29d3397e01cab7166884ab04
description:
  Obtiene un dashboard completo de Metabase incluyendo todas sus dashcards (cards posicionadas en el dashboard), tabs y parametros. Endpoint: GET /api/dashboard/:id.

example:
  ```go
  dashboard, err := MetabaseGetDashboard(client, 1)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(dashboard["name"])

  // Acceder a las cards del dashboard
  dashcards := dashboard["dashcards"].([]any)
  for _, dc := range dashcards {
      card := dc.(map[string]any)
      fmt.Printf("Card ID: %v, Position: (%v, %v)\n",
          card["card_id"], card["col"], card["row"])
  }
  ```

notes:
  ### Campos principales

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashboard |
  | name | string | Nombre |
  | description | string | Descripcion |
  | dashcards | []map | Array de dashcards (cards posicionadas) |
  | parameters | []map | Filtros del dashboard |
  | tabs | []map | Tabs del dashboard |
  | collection_id | float64 | Coleccion contenedora |
  | archived | bool | Archivado |

  ### Estructura de cada dashcard

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashcard (positivo) |
  | card_id | float64 | ID de la card/pregunta asociada |
  | card | map | Objeto card completo |
  | size_x | float64 | Ancho en grid (1-18) |
  | size_y | float64 | Alto en grid |
  | col | float64 | Columna en grid (0-based) |
  | row | float64 | Fila en grid (0-based) |
  | dashboard_tab_id | float64 | Tab al que pertenece (null = sin tabs) |
  | parameter_mappings | []map | Mapeo de filtros a la card |
  | visualization_settings | map | Settings de visualizacion |

  Usar estos datos para construir el payload de MetabaseUpdateDashboard.

code:
  package infra

  import "fmt"

  // MetabaseGetDashboard obtiene un dashboard completo de Metabase incluyendo sus cards.
  func MetabaseGetDashboard(client MetabaseClient, dashboardID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get dashboard %d: %w", dashboardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard"}],"output":"mapa con dashboard completo incluyendo dashcards, tabs, parametros"}


id: metabase_get_user_go_infra
name: metabase_get_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error)
description: Obtiene los detalles de un usuario de Metabase por su ID numerico. Endpoint: GET /api/user/:id.
tags: ["metabase","user","get","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_get_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f4ab4b962dc122cde8cfe823255417da01d6cb2190219b46ea5eb673f823730a
example:
  ```go
  user, err := MetabaseGetUser(client, 1)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println(user["email"], user["first_name"])
  ```

notes:
  Retorna el objeto usuario completo como map. Error 404 si el ID no existe.

  ### Campos del usuario retornado

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico |
  | email | string | Email |
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | is_superuser | bool | Es admin |
  | is_active | bool | Esta activo |
  | common_name | string | Nombre completo |
  | date_joined | string | Fecha de creacion |
  | last_login | string | Ultimo login |
  | group_ids | []float64 | IDs de grupos |
  | locale | string | Locale del usuario |

code:
  package infra

  import "fmt"

  // MetabaseGetUser obtiene un usuario de Metabase por su ID.
  func MetabaseGetUser(client MetabaseClient, userID int) (map[string]any, error) {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase get user %d: %w", userID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"userID","desc":"ID del usuario"}],"output":"mapa con detalles del usuario (email, nombre, grupo, permisos, etc.)"}


id: metabase_list_cards_go_infra
name: metabase_list_cards
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error)
tags: ["metabase","card","question","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_cards.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 87fa81e68bd21665cf2843762ee756ff80d02c97081b8bb1c9c7a7cf7064b378
description:
  Lista preguntas/cards de Metabase con filtro opcional. Retorna array de cards. Endpoint: GET /api/card.

example:
  ```go
  // Listar todas las cards
  cards, err := MetabaseListCards(client, "all", 0)

  // Solo mis preguntas
  cards, err := MetabaseListCards(client, "mine", 0)

  // Cards de una database especifica
  cards, err := MetabaseListCards(client, "database", 1)

  // Cards archivadas
  cards, err := MetabaseListCards(client, "archived", 0)
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | filter | string | no | "all", "mine", "fav", "archived", "recent", "popular", "database", "table". Vacio = todas |
  | modelID | int | no | ID de database/tabla. Solo aplica con filter "database" o "table". 0 = ignorar |

  No tiene paginacion con offset/limit. Retorna todas las cards que coinciden.

  ### Campos principales de cada card

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico de la card |
  | name | string | Nombre de la pregunta |
  | description | string | Descripcion |
  | display | string | Tipo de visualizacion (table, bar, line, pie, etc.) |
  | collection_id | float64 | ID de la coleccion/carpeta |
  | database_id | float64 | ID de la database |
  | creator_id | float64 | ID del creador |
  | archived | bool | Esta archivada |
  | dataset_query | map | Query de la card (native o structured) |

code:
  package infra

  import "fmt"

  // MetabaseListCards lista preguntas/cards de Metabase.
  // filter: "all", "mine", "fav", "archived", "recent", "popular", "database", "table" (vacio = todas).
  // modelID: ID de database o tabla cuando filter es "database" o "table" (0 = ignorar).
  func MetabaseListCards(client MetabaseClient, filter string, modelID int) ([]map[string]any, error) {
  	path := "/api/card"
  	sep := "?"
  	if filter != "" {
  		path += sep + "f=" + filter
  		sep = "&"
  	}
  	if modelID > 0 {
  		path += fmt.Sprintf("%smodel_id=%d", sep, modelID)
  	}

  	result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list cards: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"filter","desc":"filtro opcional (all, mine, fav, archived, recent, popular, database, table)"},{"name":"modelID","desc":"ID de database/tabla para filtros database/table (0 = ignorar)"}],"output":"slice de maps con detalles de cada card (ID, nombre, query, visualizacion)"}


id: metabase_list_dashboards_go_infra
name: metabase_list_dashboards
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error)
tags: ["metabase","dashboard","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_dashboards.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 806f9c4e2c3dfe2b64eeb36e7aaba01804e04a6c62e5b042ef70cee396e3813e
description:
  Lista dashboards de Metabase con filtro opcional. Retorna array de dashboards resumidos (sin dashcards). Endpoint: GET /api/dashboard.

example:
  ```go
  // Listar todos los dashboards
  dashboards, err := MetabaseListDashboards(client, "all")

  // Solo mis dashboards
  dashboards, err := MetabaseListDashboards(client, "mine")

  // Dashboards archivados
  dashboards, err := MetabaseListDashboards(client, "archived")
  ```

notes:
  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | filter | string | no | "all", "mine", "archived". Vacio = todas |

  Retorna dashboards resumidos (sin cards). Para ver las cards de un dashboard, usar MetabaseGetDashboard.

  ### Campos principales de cada dashboard

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID del dashboard |
  | name | string | Nombre |
  | description | string | Descripcion |
  | collection_id | float64 | Coleccion contenedora |
  | creator_id | float64 | ID del creador |
  | archived | bool | Archivado |
  | created_at | string | Fecha creacion |

code:
  package infra

  import "fmt"

  // MetabaseListDashboards lista dashboards de Metabase.
  // filter: "all", "mine" o "archived" (vacio = todas).
  func MetabaseListDashboards(client MetabaseClient, filter string) ([]map[string]any, error) {
  	path := "/api/dashboard"
  	if filter != "" {
  		path += "?f=" + filter
  	}

  	result, err := metabaseRequestList("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list dashboards: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"filter","desc":"filtro opcional (all, mine, archived o vacio para todas)"}],"output":"slice de maps con resumen de cada dashboard (sin dashcards completas)"}


id: metabase_list_users_go_infra
name: metabase_list_users
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","list","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_list_users.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 087b29382631aba55ab030e4da4809ed0eece23a95ef500228d1b6f7bf53291e
signature:
  func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error)

description:
  Lista usuarios de una instancia Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user. Requiere permisos de superusuario.

example:
  ```go
  client, _ := MetabaseAuth("http://localhost:3000", "admin@example.com", "pass")

  // Listar todos los usuarios activos
  users, err := MetabaseListUsers(client, "active", "", 0, 0)

  // Buscar usuario por email
  users, err := MetabaseListUsers(client, "", "john@", 10, 0)

  // Listar desactivados
  users, err := MetabaseListUsers(client, "deactivated", "", 25, 0)
  ```

notes:
  Retorna un map con la estructura paginada de Metabase:
  - `data`: array de objetos usuario (id, email, first_name, last_name, is_superuser, etc.)
  - `total`: numero total de usuarios que coinciden
  - `limit`: tamanio de pagina usado
  - `offset`: offset usado

  ### Parametros para un LLM

  | Parametro | Tipo | Requerido | Descripcion |
  |-----------|------|-----------|-------------|
  | client | MetabaseClient | si | Cliente autenticado |
  | status | string | no | "active" (default), "deactivated", "all" |
  | query | string | no | Filtro por nombre o email |
  | limit | int | no | Tamanio de pagina (0 = default Metabase) |
  | offset | int | no | Offset para paginacion (0 = inicio) |

  ### Campos del usuario retornado

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | id | float64 | ID numerico del usuario |
  | email | string | Email unico |
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | is_superuser | bool | Es admin |
  | is_active | bool | Esta activo |
  | common_name | string | Nombre completo |
  | last_login | string | Fecha ultimo login |

code:
  package infra

  import "fmt"

  // MetabaseListUsers lista usuarios de Metabase con filtros opcionales.
  // status: "active", "deactivated" o "all" (vacio = "active").
  // query: filtro por nombre o email (vacio = sin filtro).
  // limit/offset: paginacion (0 = valores por defecto de Metabase).
  func MetabaseListUsers(client MetabaseClient, status, query string, limit, offset int) (map[string]any, error) {
  	path := "/api/user?"
  	if status != "" {
  		path += "status=" + status + "&"
  	}
  	if query != "" {
  		path += "query=" + query + "&"
  	}
  	if limit > 0 {
  		path += fmt.Sprintf("limit=%d&", limit)
  	}
  	if offset > 0 {
  		path += fmt.Sprintf("offset=%d&", offset)
  	}

  	result, err := metabaseRequest("GET", client.BaseURL, client.Token, path, nil)
  	if err != nil {
  		return nil, fmt.Errorf("metabase list users: %w", err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"status","desc":"filtro por estado (active, deactivated, all o vacio)"},{"name":"query","desc":"filtro por nombre o email (vacio para sin filtro)"},{"name":"limit","desc":"tamanio de pagina (0 = default de Metabase)"},{"name":"offset","desc":"offset para paginacion (0 = inicio)"}],"output":"mapa con data (slice de usuarios), total, limit, offset"}


id: metabase_update_card_go_infra
name: metabase_update_card
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","update","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_card.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 24fe499f3ca6b1541be475c9ba77e0f5081a458395bb60a812e1ac38af54a8c8
signature:
  func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza campos de una card/pregunta en Metabase. Solo se modifican los campos incluidos en el map. Endpoint: PUT /api/card/:id.

example:
  ```go
  // Cambiar nombre y descripcion
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "name":        "Updated Revenue Chart",
      "description": "Now includes refunds",
  })

  // Archivar una card (soft-delete)
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "archived": true,
  })

  // Mover a otra coleccion
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "collection_id": 10,
  })

  // Cambiar la query SQL
  card, err := MetabaseUpdateCard(client, 42, map[string]any{
      "dataset_query": map[string]any{
          "database": 1,
          "type":     "native",
          "native":   map[string]any{"query": "SELECT * FROM users LIMIT 100"},
      },
  })
  ```

notes:
  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | name | string | Nombre de la pregunta |
  | description | string | Descripcion |
  | display | string | Tipo de visualizacion |
  | dataset_query | map | Query SQL o MBQL |
  | visualization_settings | map | Config de visualizacion |
  | collection_id | int | Mover a otra coleccion |
  | archived | bool | Archivar/desarchivar (soft-delete) |
  | enable_embedding | bool | Habilitar embedding publico |
  | embedding_params | map | Parametros de embedding |

  Solo incluir los campos que se quieren cambiar.
  Para eliminar permanentemente usar MetabaseDeleteCard. Para soft-delete usar archived: true.

code:
  package infra

  import "fmt"

  // MetabaseUpdateCard actualiza campos de una card/pregunta en Metabase.
  // fields es un map con los campos a actualizar.
  // Campos comunes: name, description, display, dataset_query, visualization_settings,
  // collection_id, archived, enable_embedding.
  func MetabaseUpdateCard(client MetabaseClient, cardID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/card/%d", cardID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update card %d: %w", cardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"cardID","desc":"ID de la card/pregunta a actualizar"},{"name":"fields","desc":"mapa con los campos a modificar (name, description, display, dataset_query, archived, etc.)"}],"output":"mapa con los detalles de la card actualizada"}


id: metabase_update_dashboard_go_infra
name: metabase_update_dashboard
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","update","cards","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_dashboard.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 319678921fda2088275e1e6d0df339dd79c6170c1dcc1102876de502f32b203d
signature:
  func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza un dashboard en Metabase incluyendo metadata, cards y tabs. El campo dashcards representa el estado completo deseado: cards nuevas con ID negativo, existentes con ID positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id.

example:
  ```go
  // Cambiar nombre
  MetabaseUpdateDashboard(client, 1, map[string]any{
      "name": "Updated Dashboard",
  })

  // Agregar una card al dashboard
  // Primero obtener las dashcards existentes
  dash, _ := MetabaseGetDashboard(client, 1)
  existingCards := dash["dashcards"].([]any)

  // Construir nuevo array con las existentes + la nueva
  dashcards := make([]map[string]any, 0)
  for _, dc := range existingCards {
      dashcards = append(dashcards, dc.(map[string]any))
  }
  // Agregar nueva card (ID negativo = nueva)
  dashcards = append(dashcards, map[string]any{
      "id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0,
  })

  MetabaseUpdateDashboard(client, 1, map[string]any{
      "dashcards": dashcards,
  })

  // Archivar dashboard (soft-delete)
  MetabaseUpdateDashboard(client, 1, map[string]any{"archived": true})
  ```

notes:
  ### Gestion de dashcards (IMPORTANTE)

  El array `dashcards` representa el **estado completo deseado** del dashboard:

  | Accion | Como hacerlo |
  |--------|-------------|
  | Agregar card | Incluir con **ID negativo** (-1, -2, etc.) |
  | Actualizar card | Incluir con su **ID positivo** existente |
  | Eliminar card | **Omitir** del array |
  | No cambiar cards | No incluir el campo dashcards |

  **Flujo tipico para agregar una card:**
  1. `MetabaseGetDashboard` para obtener dashcards existentes
  2. Copiar las existentes al nuevo array
  3. Agregar la nueva con ID negativo
  4. Enviar el array completo

  ### Estructura de una dashcard

  ```go
  map[string]any{
      "id":                      -1,              // negativo = nueva, positivo = existente
      "card_id":                 42,              // ID de la card/pregunta
      "size_x":                  6,               // ancho (1-18)
      "size_y":                  4,               // alto
      "col":                     0,               // columna (0-based)
      "row":                     0,               // fila (0-based)
      "dashboard_tab_id":        nil,             // tab (nil = sin tabs)
      "parameter_mappings":      []map[string]any{}, // mapeo de filtros
      "visualization_settings":  map[string]any{},   // settings custom
  }
  ```

  ### Gestion de tabs

  ```go
  map[string]any{
      "tabs": []map[string]any{
          {"id": 1, "name": "Overview"},           // tab existente
          {"id": -1, "name": "Details"},           // tab nuevo (ID negativo)
      },
  }
  ```

  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | name | string | Nombre del dashboard |
  | description | string | Descripcion |
  | archived | bool | Archivar/desarchivar |
  | dashcards | []map | Estado completo de cards |
  | tabs | []map | Tabs del dashboard |
  | parameters | []map | Filtros del dashboard |
  | collection_id | int | Mover a otra coleccion |

code:
  package infra

  import "fmt"

  // MetabaseUpdateDashboard actualiza un dashboard en Metabase.
  // fields puede incluir metadata del dashboard Y/O la lista completa de dashcards y tabs.
  //
  // Para gestionar cards en el dashboard, incluir "dashcards" en fields:
  //   - Agregar card: incluirla con ID negativo (ej: -1, -2)
  //   - Actualizar card: incluirla con su ID positivo existente
  //   - Eliminar card: omitirla del array (el array es el estado deseado completo)
  //
  // Campos comunes: name, description, archived, parameters, dashcards, tabs, collection_id.
  func MetabaseUpdateDashboard(client MetabaseClient, dashboardID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/dashboard/%d", dashboardID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update dashboard %d: %w", dashboardID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado"},{"name":"dashboardID","desc":"ID del dashboard a actualizar"},{"name":"fields","desc":"mapa con campos a modificar (name, description, dashcards, tabs, archived, etc.)"}],"output":"mapa con los detalles del dashboard actualizado"}


id: metabase_update_user_go_infra
name: metabase_update_user
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","update","api"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/metabase_update_user.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 219f4406a5e9ab48207f662a753bbd5ee03ee2322bab227170d4f88072dcff89
signature:
  func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error)

description:
  Actualiza campos de un usuario en Metabase. Solo se modifican los campos incluidos en el map. Requiere permisos de superusuario. Endpoint: PUT /api/user/:id.

example:
  ```go
  // Cambiar nombre
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "first_name": "Jane",
      "last_name":  "Smith",
  })

  // Promover a admin
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "is_superuser": true,
  })

  // Cambiar grupos
  user, err := MetabaseUpdateUser(client, 5, map[string]any{
      "group_ids": []int{1, 3, 5},
  })
  ```

notes:
  ### Campos actualizables

  | Campo | Tipo | Descripcion |
  |-------|------|-------------|
  | first_name | string | Nombre |
  | last_name | string | Apellido |
  | email | string | Email (debe ser unico) |
  | is_superuser | bool | Permisos de admin |
  | group_ids | []int | IDs de grupos del usuario |
  | locale | string | Locale (ej: "es", "en") |
  | login_attributes | map | Atributos para sandboxing |

  Solo incluir los campos que se quieren cambiar. Los demas se mantienen sin modificar.
  Retorna el objeto usuario actualizado.

code:
  package infra

  import "fmt"

  // MetabaseUpdateUser actualiza campos de un usuario en Metabase.
  // fields es un map con los campos a actualizar. Campos validos:
  // first_name, last_name, email, is_superuser, group_ids, locale, login_attributes.
  func MetabaseUpdateUser(client MetabaseClient, userID int, fields map[string]any) (map[string]any, error) {
  	path := fmt.Sprintf("/api/user/%d", userID)

  	result, err := metabaseRequest("PUT", client.BaseURL, client.Token, path, fields)
  	if err != nil {
  		return nil, fmt.Errorf("metabase update user %d: %w", userID, err)
  	}

  	return result, nil
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente MetabaseClient autenticado con permisos admin"},{"name":"userID","desc":"ID del usuario a actualizar"},{"name":"fields","desc":"mapa con campos a modificar (first_name, last_name, email, is_superuser, group_ids, locale)"}],"output":"mapa con los detalles del usuario actualizado"}


id: migration_create_go_infra
name: migration_create
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationCreate(dir string, name string) (string, error)
tags: ["migration","database","sql","schema","sqlite","create","file"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath","regexp","strconv","strings"]
test_file_path: functions/infra/migration_create_test.go
file_path: functions/infra/migration_create.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 320541be5222bceacfcdddaa2970b88a283f3635f8d1b387c1a9c7e34c5782f8
description:
  Crea un archivo .sql de migracion con template -- +up / -- +down en el directorio indicado. Calcula automaticamente el siguiente numero de version escaneando archivos .sql existentes. Retorna el path absoluto del archivo creado.

example:
  ```go
  // Primera migracion
  path, err := MigrationCreate("apps/my_app/migrations", "create_users")
  // path = "apps/my_app/migrations/001_create_users.sql"
  // Contenido del archivo:
  // -- 001_create_users.sql
  //
  // -- +up
  //
  //
  // -- +down
  //

  // Segunda migracion (ya existe 001)
  path2, err := MigrationCreate("apps/my_app/migrations", "add_email")
  // path2 = "apps/my_app/migrations/002_add_email.sql"
  ```

tests:
  ["directorio vacio crea archivo con version 001","directorio con migraciones existentes calcula siguiente version","nombre invalido retorna error","directorio inexistente se crea automaticamente"]

notes:
  Crea el directorio si no existe (`os.MkdirAll`). La version se calcula encontrando el maximo numero existente entre los archivos `NNN_*.sql` del directorio y sumando 1. El template generado tiene los marcadores vacios para que el desarrollador complete el SQL. Nombre valido: `^[a-zA-Z][a-zA-Z0-9_]*$` — no puede empezar con numero ni contener espacios o guiones.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  	"regexp"
  	"strconv"
  	"strings"
  )

  var migrationFilePattern = regexp.MustCompile(`^(\d+)_[a-zA-Z0-9_]+\.sql$`)
  var migrationNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)

  // MigrationCreate creates a new migration file in dir with the given name.
  // It calculates the next version by scanning existing .sql files in dir.
  // The filename follows the pattern NNN_name.sql (e.g. 003_add_index.sql).
  // Returns the absolute path of the created file.
  func MigrationCreate(dir, name string) (string, error) {
  	if !migrationNamePattern.MatchString(name) {
  		return "", fmt.Errorf("migration_create: name %q must match [a-zA-Z][a-zA-Z0-9_]*", name)
  	}

  	if err := os.MkdirAll(dir, 0o755); err != nil {
  		return "", fmt.Errorf("migration_create: cannot create directory %q: %w", dir, err)
  	}

  	next, err := nextMigrationVersion(dir)
  	if err != nil {
  		return "", fmt.Errorf("migration_create: %w", err)
  	}

  	filename := fmt.Sprintf("%03d_%s.sql", next, name)
  	path := filepath.Join(dir, filename)

  	template := fmt.Sprintf("-- %s\n\n-- +up\n\n\n-- +down\n\n", filename)

  	if err := os.WriteFile(path, []byte(template), 0o644); err != nil {
  		return "", fmt.Errorf("migration_create: cannot write file %q: %w", path, err)
  	}

  	return path, nil
  }

  // nextMigrationVersion returns the next version number by scanning .sql files in dir.
  // Returns 1 if the directory is empty or has no migration files.
  func nextMigrationVersion(dir string) (int, error) {
  	entries, err := os.ReadDir(dir)
  	if err != nil {
  		if os.IsNotExist(err) {
  			return 1, nil
  		}
  		return 0, fmt.Errorf("cannot read directory: %w", err)
  	}

  	max := 0
  	for _, e := range entries {
  		if e.IsDir() {
  			continue
  		}
  		name := e.Name()
  		if !strings.HasSuffix(strings.ToLower(name), ".sql") {
  			continue
  		}
  		if !migrationFilePattern.MatchString(name) {
  			continue
  		}

  		idx := strings.Index(name, "_")
  		if idx < 0 {
  			continue
  		}
  		v, err := strconv.Atoi(name[:idx])
  		if err != nil {
  			continue
  		}
  		if v > max {
  			max = v
  		}
  	}

  	return max + 1, nil
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio donde crear el archivo de migracion (se crea si no existe, ej: apps/my_app/migrations)"},{"name":"name","desc":"nombre descriptivo de la migracion en snake_case (ej: create_users, add_email_column). Solo letras, numeros y underscore."}],"output":"path absoluto del archivo .sql creado (ej: apps/my_app/migrations/001_create_users.sql)"}


id: migration_down_go_infra
name: migration_down
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationDown(db *sql.DB, n int) ([]Migration, error)
tags: ["migration","database","sql","schema","sqlite","rollback","down"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","time"]
test_file_path: functions/infra/migration_down_test.go
file_path: functions/infra/migration_down.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: e6be1d3422ac975a728bbca0d443788924ee080c15f27fb103946917bcd7cdb6
description:
  Revierte las ultimas n migraciones aplicadas ejecutando su down_sql guardado en la tabla _migrations. Las reversiones ocurren en orden inverso de version (la mas alta primero). Cada reversion corre en su propia transaccion. Retorna las migraciones revertidas.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  // Revertir la ultima migracion aplicada
  reverted, err := MigrationDown(db, 1)
  if err != nil {
      log.Fatalf("rollback failed: %v", err)
  }
  for _, m := range reverted {
      fmt.Printf("Reverted: %03d_%s\n", m.Version, m.Name)
  }
  // Reverted: 003_add_audit_log
  ```

tests:
  ["revertir ultima migracion elimina registro y ejecuta down_sql","revertir n migraciones revierte en orden descendente","n cero no revierte nada","base de datos sin migraciones retorna slice vacio"]

notes:
  Usa el `down_sql` almacenado en `_migrations`, no el archivo en disco. Esto garantiza que el rollback funciona aunque el archivo haya sido modificado o eliminado. Si `down_sql` esta vacio, solo se elimina el registro de `_migrations` sin ejecutar SQL. ATENCION: `MigrationDown` es destructiva — un `DROP TABLE` en el down elimina datos. Diseñada para desarrollo y no para revertir en produccion con datos vivos.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"time"
  )

  // MigrationDown reverts the last n applied migrations by executing their down_sql
  // from the _migrations table. Migrations are reverted in reverse version order
  // (highest version first). Each reversion runs in its own transaction.
  // Returns the list of reverted migrations. If n <= 0, no migrations are reverted.
  func MigrationDown(db *sql.DB, n int) ([]Migration, error) {
  	if n <= 0 {
  		return nil, nil
  	}

  	// Fetch last n applied migrations in descending order
  	const query = `
  		SELECT version, name, up_sql, down_sql, applied_at
  		FROM _migrations
  		ORDER BY version DESC
  		LIMIT ?`

  	rows, err := db.Query(query, n)
  	if err != nil {
  		return nil, fmt.Errorf("migration_down: query _migrations: %w", err)
  	}
  	defer rows.Close()

  	var toRevert []Migration
  	for rows.Next() {
  		var m Migration
  		var appliedAtStr string
  		if err := rows.Scan(&m.Version, &m.Name, &m.UpSQL, &m.DownSQL, &appliedAtStr); err != nil {
  			return nil, fmt.Errorf("migration_down: scan row: %w", err)
  		}
  		m.AppliedAt, _ = time.Parse("2006-01-02 15:04:05", appliedAtStr)
  		toRevert = append(toRevert, m)
  	}
  	if err := rows.Err(); err != nil {
  		return nil, fmt.Errorf("migration_down: rows error: %w", err)
  	}

  	// Revert each migration in its own transaction (already in DESC order)
  	var reverted []Migration
  	for _, m := range toRevert {
  		if err := revertMigration(db, m); err != nil {
  			return reverted, fmt.Errorf("migration_down: reverting version %d (%s): %w", m.Version, m.Name, err)
  		}
  		reverted = append(reverted, m)
  	}

  	return reverted, nil
  }

  // revertMigration executes a migration's DownSQL within a transaction and removes it
  // from _migrations. If DownSQL is empty, only the record is removed.
  func revertMigration(db *sql.DB, m Migration) error {
  	tx, err := db.Begin()
  	if err != nil {
  		return fmt.Errorf("begin transaction: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Execute the down SQL if present
  	if m.DownSQL != "" {
  		if _, err := tx.Exec(m.DownSQL); err != nil {
  			return fmt.Errorf("exec down_sql: %w", err)
  		}
  	}

  	// Remove the migration record
  	if _, err := tx.Exec("DELETE FROM _migrations WHERE version = ?", m.Version); err != nil {
  		return fmt.Errorf("delete from _migrations: %w", err)
  	}

  	return tx.Commit()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite con la tabla _migrations"},{"name":"n","desc":"numero de migraciones a revertir (las ultimas n en orden descendente de version). Si n \u003c= 0 no hace nada."}],"output":"slice de Migration con las migraciones que fueron revertidas (en orden descendente de version)"}


id: migration_parse_go_infra
name: migration_parse
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func MigrationParse(filename string, content string) (Migration, error)
tags: ["migration","database","sql","schema","sqlite","parse"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
imports: ["fmt","strconv","strings"]
test_file_path: functions/infra/migration_parse_test.go
file_path: functions/infra/migration_parse.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 41b963fa86c98eaa2e570a1954bd9b4cca182b130c2df8d761db3922c9907cda
description:
  Parsea el nombre de archivo y el contenido SQL de una migracion. Extrae version y nombre del filename (patron NNN_nombre.sql) y separa bloques up/down por marcadores -- +up / -- +down. Error si el formato es invalido o falta el bloque up.

example:
  ```go
  content := `
  -- +up
  CREATE TABLE users (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL
  );

  -- +down
  DROP TABLE IF EXISTS users;
  `
  m, err := MigrationParse("001_create_users.sql", content)
  // m.Version  = 1
  // m.Name     = "create_users"
  // m.UpSQL    = "CREATE TABLE users (\n    id TEXT PRIMARY KEY,\n    name TEXT NOT NULL\n);"
  // m.DownSQL  = "DROP TABLE IF EXISTS users;"
  ```

tests:
  ["archivo valido con up y down retorna Migration correcta","archivo sin bloque down retorna DownSQL vacio sin error","filename sin separador underscore retorna error","version no numerica retorna error","bloque up vacio retorna error","version cero retorna error","nombre descriptivo vacio retorna error","filename con path completo extrae nombre base"]

notes:
  Funcion pura — no hace I/O. El marcador `-- +up` es obligatorio; `-- +down` es opcional (retorna DownSQL vacio). Si el archivo tiene down antes que up, se parsea igualmente. Los bloques se recortan con `strings.TrimSpace`. El formato de version es un entero positivo con cualquier numero de digitos (001, 01, 1 son equivalentes).

code:
  package infra

  import (
  	"fmt"
  	"strconv"
  	"strings"
  )

  // MigrationParse parses a migration filename and its SQL content into a Migration.
  // The filename must follow the pattern NNN_name.sql (e.g. 001_create_users.sql).
  // The content must contain a -- +up marker; -- +down is optional but recommended.
  // Returns error if the filename format is invalid or the -- +up block is missing.
  func MigrationParse(filename, content string) (Migration, error) {
  	// Strip path prefix if any
  	base := filename
  	if idx := strings.LastIndex(base, "/"); idx >= 0 {
  		base = base[idx+1:]
  	}
  	if idx := strings.LastIndex(base, "\\"); idx >= 0 {
  		base = base[idx+1:]
  	}

  	// Remove .sql extension
  	name := base
  	if strings.HasSuffix(strings.ToLower(name), ".sql") {
  		name = name[:len(name)-4]
  	}

  	// Split on first underscore to get version and descriptive name
  	idx := strings.Index(name, "_")
  	if idx < 0 {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q must follow pattern NNN_name.sql", filename)
  	}

  	versionStr := name[:idx]
  	descriptiveName := name[idx+1:]

  	version, err := strconv.Atoi(versionStr)
  	if err != nil || version <= 0 {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q version %q must be a positive integer", filename, versionStr)
  	}

  	if descriptiveName == "" {
  		return Migration{}, fmt.Errorf("migration_parse: filename %q must have a non-empty descriptive name after the version", filename)
  	}

  	// Parse up/down blocks
  	upSQL, downSQL, err := parseMigrationBlocks(content)
  	if err != nil {
  		return Migration{}, fmt.Errorf("migration_parse: %w", err)
  	}

  	return Migration{
  		Version: version,
  		Name:    descriptiveName,
  		UpSQL:   upSQL,
  		DownSQL: downSQL,
  	}, nil
  }

  // parseMigrationBlocks splits SQL content by -- +up and -- +down markers.
  // Returns (upSQL, downSQL, error). The -- +up block is required.
  func parseMigrationBlocks(content string) (string, string, error) {
  	const markerUp = "-- +up"
  	const markerDown = "-- +down"

  	// Normalize line endings
  	content = strings.ReplaceAll(content, "\r\n", "\n")

  	upIdx := indexMarker(content, markerUp)
  	if upIdx < 0 {
  		return "", "", fmt.Errorf("missing -- +up marker in content")
  	}

  	downIdx := indexMarker(content, markerDown)

  	var upSQL, downSQL string

  	if downIdx < 0 {
  		// No down block
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
  	} else if downIdx > upIdx {
  		// Normal order: up first, then down
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp) : downIdx])
  		downSQL = strings.TrimSpace(content[downIdx+len(markerDown):])
  	} else {
  		// Down before up — still valid, just unusual
  		downSQL = strings.TrimSpace(content[downIdx+len(markerDown) : upIdx])
  		upSQL = strings.TrimSpace(content[upIdx+len(markerUp):])
  	}

  	if upSQL == "" {
  		return "", "", fmt.Errorf("-- +up block is empty")
  	}

  	return upSQL, downSQL, nil
  }

  // indexMarker finds the index of a marker at the start of any line in content.
  func indexMarker(content, marker string) int {
  	lines := strings.Split(content, "\n")
  	pos := 0
  	for _, line := range lines {
  		trimmed := strings.TrimSpace(line)
  		if trimmed == marker {
  			return pos
  		}
  		pos += len(line) + 1 // +1 for the newline
  	}
  	return -1
  }

params_schema:
  {"params":[{"name":"filename","desc":"nombre del archivo de migracion (ej: 001_create_users.sql). Puede incluir path completo."},{"name":"content","desc":"contenido completo del archivo .sql con marcadores -- +up y -- +down"}],"output":"Migration con version, nombre, up_sql y down_sql extraidos del archivo"}


id: migration_status_go_infra
name: migration_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error)
tags: ["migration","database","sql","schema","sqlite","status","list"]
uses_functions: ["migration_parse_go_infra"]
uses_types: ["MigrationStatus_go_infra"]
returns: ["MigrationStatus_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","sort","strings","time"]
test_file_path: functions/infra/migration_status_test.go
file_path: functions/infra/migration_status.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: c2c1e4bb62c0683b28cecc3dd993cbca6e0f4d0e894f0f13781daefb0b617d79
description:
  Cruza los archivos .sql del directorio con los registros en _migrations y retorna una lista ordenada por version con el estado de cada migracion (applied/pending). Migraciones en BD pero sin archivo en disco se marcan como orphaned. Si _migrations no existe aun, todas las migraciones del directorio aparecen como pending.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  statuses, err := MigrationGetStatus(db, "apps/my_app/migrations")
  if err != nil {
      log.Fatal(err)
  }
  for _, s := range statuses {
      if s.Applied {
          fmt.Printf("%03d %-30s applied at %s\n", s.Version, s.Name, s.AppliedAt.Format(time.RFC3339))
      } else {
          fmt.Printf("%03d %-30s pending\n", s.Version, s.Name)
      }
  }
  // 001 create_users                  applied at 2026-04-13T10:30:00Z
  // 002 add_roles                     applied at 2026-04-13T10:30:00Z
  // 003 add_audit_log                 pending
  ```

tests:
  ["migraciones en disco pero no en BD aparecen como pending","migraciones aplicadas aparecen con Applied=true y AppliedAt","migraciones aplicadas sin archivo en disco aparecen como orphaned","base de datos sin tabla _migrations retorna todas como pending"]

notes:
  Combina informacion de disco (archivos .sql) y BD (tabla _migrations) para dar una vision completa del estado. Las migraciones "orphaned" son aquellas que aparecen en `_migrations` pero ya no tienen archivo en disco — esto puede indicar que el archivo fue eliminado despues de aplicarse. La tabla `_migrations` se crea con `MigrationUp`; si no existe aun, `MigrationStatus` las trata todas como pending.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"sort"
  	"strings"
  	"time"
  )

  // MigrationGetStatus crosses migration files on disk with records in _migrations,
  // returning a sorted list of MigrationStatus entries ordered by version ascending.
  // Migrations present in _migrations but not on disk are included with Applied=true
  // and marked as orphaned in their Name (suffix " (orphaned)").
  // If _migrations does not exist yet, all file-based migrations are returned as pending.
  func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error) {
  	// Load files from disk (non-fatal if dir is empty or missing)
  	fileMigrations, err := loadMigrationsFromDir(dir)
  	if err != nil && !strings.Contains(err.Error(), "cannot read migrations directory") {
  		return nil, fmt.Errorf("migration_status: %w", err)
  	}
  	if err != nil {
  		// Directory doesn't exist — treat as empty
  		fileMigrations = nil
  	}

  	// Build a map from version to file migration
  	fileMap := make(map[int]Migration, len(fileMigrations))
  	for _, m := range fileMigrations {
  		fileMap[m.Version] = m
  	}

  	// Load applied migrations from DB (_migrations may not exist yet)
  	dbMap, err := loadAppliedMigrations(db)
  	if err != nil {
  		return nil, fmt.Errorf("migration_status: %w", err)
  	}

  	// Merge: collect all known versions
  	allVersions := make(map[int]struct{})
  	for v := range fileMap {
  		allVersions[v] = struct{}{}
  	}
  	for v := range dbMap {
  		allVersions[v] = struct{}{}
  	}

  	// Build status list
  	var statuses []MigrationStatus
  	for v := range allVersions {
  		dbEntry, inDB := dbMap[v]
  		fileEntry, inFile := fileMap[v]

  		name := fileEntry.Name
  		if !inFile && inDB {
  			// Orphaned: applied but no file on disk
  			name = dbEntry.Name + " (orphaned)"
  		}

  		statuses = append(statuses, MigrationStatus{
  			Version:   v,
  			Name:      name,
  			Applied:   inDB,
  			AppliedAt: dbEntry.AppliedAt, // zero value if not in DB
  		})
  	}

  	sort.Slice(statuses, func(i, j int) bool {
  		return statuses[i].Version < statuses[j].Version
  	})

  	return statuses, nil
  }

  // migrationDBRecord holds data from the _migrations table.
  type migrationDBRecord struct {
  	Name      string
  	AppliedAt time.Time
  }

  // loadAppliedMigrations queries _migrations and returns a map of version -> record.
  // If the table does not exist, returns an empty map without error.
  func loadAppliedMigrations(db *sql.DB) (map[int]migrationDBRecord, error) {
  	result := make(map[int]migrationDBRecord)

  	rows, err := db.Query("SELECT version, name, applied_at FROM _migrations ORDER BY version ASC")
  	if err != nil {
  		// Table doesn't exist yet — return empty map
  		if strings.Contains(err.Error(), "no such table") {
  			return result, nil
  		}
  		return nil, fmt.Errorf("query _migrations: %w", err)
  	}
  	defer rows.Close()

  	for rows.Next() {
  		var version int
  		var name, appliedAtStr string
  		if err := rows.Scan(&version, &name, &appliedAtStr); err != nil {
  			return nil, fmt.Errorf("scan _migrations row: %w", err)
  		}
  		appliedAt, _ := time.Parse("2006-01-02 15:04:05", appliedAtStr)
  		result[version] = migrationDBRecord{Name: name, AppliedAt: appliedAt}
  	}
  	return result, rows.Err()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite (puede no tener _migrations aun)"},{"name":"dir","desc":"path al directorio con los archivos .sql de migracion (puede no existir)"}],"output":"slice de MigrationStatus ordenado por version ascendente con Applied y AppliedAt para cada migracion"}


id: migration_up_go_infra
name: migration_up
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func MigrationUp(db *sql.DB, dir string) ([]Migration, error)
tags: ["migration","database","sql","schema","sqlite","apply","up"]
uses_functions: ["migration_parse_go_infra"]
uses_types: ["Migration_go_infra"]
returns: ["Migration_go_infra"]
error_type: error_go_core
imports: ["database/sql","fmt","os","path/filepath","sort","strings"]
test_file_path: functions/infra/migration_up_test.go
file_path: functions/infra/migration_up.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: d115848cab15afce0ca52da1d29fb98c01539c2e700546e89e0770869733c636
description:
  Lee los archivos .sql del directorio, crea la tabla _migrations si no existe, y ejecuta las migraciones pendientes en orden de version. Cada migracion corre en su propia transaccion. Retorna la lista de migraciones aplicadas en esta llamada.

example:
  ```go
  db, _ := SQLiteOpen("", "apps/my_app/operations.db")
  defer db.Close()

  applied, err := MigrationUp(db, "apps/my_app/migrations")
  if err != nil {
      log.Fatalf("migration failed: %v", err)
  }
  for _, m := range applied {
      fmt.Printf("Applied: %03d_%s\n", m.Version, m.Name)
  }
  // Applied: 001_create_users
  // Applied: 002_add_roles
  ```

tests:
  ["base de datos vacia aplica todas las migraciones","migraciones ya aplicadas se omiten","migracion con SQL invalido retorna error y deja las anteriores aplicadas","directorio sin archivos sql no aplica nada"]

notes:
  Crea `_migrations` con `CREATE TABLE IF NOT EXISTS` — es idempotente. Cada migracion se ejecuta en una transaccion independiente: si falla la migracion 3, las 1 y 2 ya aplicadas permanecen. El `up_sql` y `down_sql` se guardan en `_migrations` para que el rollback funcione aunque el archivo sea modificado o eliminado posteriormente. SQLite con el driver mattn/go-sqlite3 soporta multiples sentencias en un solo `Exec`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"os"
  	"path/filepath"
  	"sort"
  	"strings"
  )

  const createMigrationsTable = `
  CREATE TABLE IF NOT EXISTS _migrations (
      version    INTEGER PRIMARY KEY,
      name       TEXT    NOT NULL,
      up_sql     TEXT    NOT NULL,
      down_sql   TEXT    NOT NULL,
      applied_at TEXT    NOT NULL DEFAULT (datetime('now'))
  )`

  // MigrationUp reads all .sql migration files from dir, creates the _migrations
  // table if it does not exist, and applies any pending migrations in version order.
  // Each migration runs in its own transaction. Returns the list of applied migrations.
  // If a migration fails, execution stops and the error is returned along with any
  // migrations that were successfully applied before the failure.
  func MigrationUp(db *sql.DB, dir string) ([]Migration, error) {
  	// Ensure _migrations table exists
  	if _, err := db.Exec(createMigrationsTable); err != nil {
  		return nil, fmt.Errorf("migration_up: cannot create _migrations table: %w", err)
  	}

  	// Load files from directory
  	allMigrations, err := loadMigrationsFromDir(dir)
  	if err != nil {
  		return nil, fmt.Errorf("migration_up: %w", err)
  	}

  	// Fetch already-applied versions
  	applied, err := appliedVersions(db)
  	if err != nil {
  		return nil, fmt.Errorf("migration_up: %w", err)
  	}

  	// Filter pending migrations
  	var pending []Migration
  	for _, m := range allMigrations {
  		if !applied[m.Version] {
  			pending = append(pending, m)
  		}
  	}

  	// Apply each pending migration in its own transaction
  	var result []Migration
  	for _, m := range pending {
  		if err := applyMigration(db, m); err != nil {
  			return result, fmt.Errorf("migration_up: applying version %d (%s): %w", m.Version, m.Name, err)
  		}
  		result = append(result, m)
  	}

  	return result, nil
  }

  // loadMigrationsFromDir reads and parses all .sql migration files from dir,
  // returning them sorted by version ascending.
  func loadMigrationsFromDir(dir string) ([]Migration, error) {
  	entries, err := os.ReadDir(dir)
  	if err != nil {
  		return nil, fmt.Errorf("cannot read migrations directory %q: %w", dir, err)
  	}

  	var migrations []Migration
  	for _, e := range entries {
  		if e.IsDir() {
  			continue
  		}
  		name := e.Name()
  		if !strings.HasSuffix(strings.ToLower(name), ".sql") {
  			continue
  		}

  		path := filepath.Join(dir, name)
  		content, err := os.ReadFile(path)
  		if err != nil {
  			return nil, fmt.Errorf("cannot read %q: %w", path, err)
  		}

  		m, err := MigrationParse(name, string(content))
  		if err != nil {
  			return nil, fmt.Errorf("parse error in %q: %w", name, err)
  		}
  		migrations = append(migrations, m)
  	}

  	sort.Slice(migrations, func(i, j int) bool {
  		return migrations[i].Version < migrations[j].Version
  	})

  	return migrations, nil
  }

  // appliedVersions returns a set of version numbers already recorded in _migrations.
  func appliedVersions(db *sql.DB) (map[int]bool, error) {
  	rows, err := db.Query("SELECT version FROM _migrations")
  	if err != nil {
  		return nil, fmt.Errorf("cannot query _migrations: %w", err)
  	}
  	defer rows.Close()

  	applied := make(map[int]bool)
  	for rows.Next() {
  		var v int
  		if err := rows.Scan(&v); err != nil {
  			return nil, fmt.Errorf("scan version: %w", err)
  		}
  		applied[v] = true
  	}
  	return applied, rows.Err()
  }

  // applyMigration executes a migration's UpSQL within a transaction and records it
  // in _migrations. If UpSQL contains multiple statements, they are executed sequentially
  // using db.Exec (SQLite supports multiple statements via the C driver).
  func applyMigration(db *sql.DB, m Migration) error {
  	tx, err := db.Begin()
  	if err != nil {
  		return fmt.Errorf("begin transaction: %w", err)
  	}
  	defer tx.Rollback() //nolint:errcheck

  	// Execute the up SQL (may contain multiple statements)
  	if _, err := tx.Exec(m.UpSQL); err != nil {
  		return fmt.Errorf("exec up_sql: %w", err)
  	}

  	// Record the migration
  	const insertSQL = `INSERT INTO _migrations (version, name, up_sql, down_sql) VALUES (?, ?, ?, ?)`
  	if _, err := tx.Exec(insertSQL, m.Version, m.Name, m.UpSQL, m.DownSQL); err != nil {
  		return fmt.Errorf("record migration: %w", err)
  	}

  	return tx.Commit()
  }

params_schema:
  {"params":[{"name":"db","desc":"conexion *sql.DB abierta a la base de datos SQLite donde aplicar las migraciones"},{"name":"dir","desc":"path al directorio que contiene los archivos .sql de migracion (ej: apps/my_app/migrations)"}],"output":"slice de Migration con las migraciones que fueron aplicadas en esta llamada (puede estar vacio si todo ya estaba aplicado)"}


id: migration_validate_go_infra
name: migration_validate
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func MigrationValidate(migrations []Migration) []string
tags: ["migration","database","sql","schema","sqlite","validate"]
uses_functions: []
uses_types: ["Migration_go_infra"]
returns: []
imports: ["fmt","sort"]
test_file_path: functions/infra/migration_validate_test.go
file_path: functions/infra/migration_validate.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 263c01c608cafa9b3531239005f34aeb09bc8934d82703884115bf64d16537d4
description:
  Verifica que una secuencia de migraciones sea valida: versiones secuenciales sin huecos comenzando en 1, sin duplicados, con up_sql y nombre no vacios. Retorna lista de errores (vacia si todo OK).

example:
  ```go
  migrations := []Migration{
      {Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (...);", DownSQL: "DROP TABLE users;"},
      {Version: 3, Name: "add_roles",    UpSQL: "CREATE TABLE roles (...);", DownSQL: "DROP TABLE roles;"},
  }
  errs := MigrationValidate(migrations)
  // errs = ["gap in versions: missing 2 (have 1 then 3)"]

  valid := []Migration{
      {Version: 1, Name: "create_users", UpSQL: "CREATE TABLE users (id TEXT PRIMARY KEY);"},
      {Version: 2, Name: "add_email",    UpSQL: "ALTER TABLE users ADD COLUMN email TEXT;"},
  }
  errs = MigrationValidate(valid)
  // errs = []  (empty — no errors)
  ```

tests:
  ["secuencia valida retorna sin errores","secuencia vacia retorna sin errores","version duplicada reporta error","hueco en versiones reporta version faltante","up_sql vacio reporta error","nombre vacio reporta error","versiones que no empiezan en 1 reportan error","multiple errores se reportan todos"]

notes:
  Funcion pura — no hace I/O, no modifica el slice de entrada. Ordena internamente una copia para detectar huecos. Todos los errores encontrados se acumulan y retornan juntos (no falla al primer error). Util antes de llamar a `MigrationUp` para detectar problemas de forma anticipada.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  )

  // MigrationValidate checks a slice of migrations for consistency errors.
  // It verifies that:
  //   - Versions are sequential starting from 1 with no gaps (1, 2, 3...)
  //   - No duplicate versions exist
  //   - Each migration has non-empty UpSQL
  //   - Each migration has a non-empty Name
  //
  // Returns a slice of human-readable error strings. An empty slice means all
  // migrations are valid. The function does not mutate the input slice.
  func MigrationValidate(migrations []Migration) []string {
  	var errs []string

  	if len(migrations) == 0 {
  		return errs
  	}

  	// Work on a sorted copy to detect gaps and duplicates
  	sorted := make([]Migration, len(migrations))
  	copy(sorted, migrations)
  	sort.Slice(sorted, func(i, j int) bool {
  		return sorted[i].Version < sorted[j].Version
  	})

  	// Check individual fields and collect duplicates
  	seen := make(map[int]int) // version -> count
  	for _, m := range sorted {
  		seen[m.Version]++

  		if m.Name == "" {
  			errs = append(errs, fmt.Sprintf("version %d has empty name", m.Version))
  		}
  		if m.UpSQL == "" {
  			errs = append(errs, fmt.Sprintf("version %d (%s) has empty up_sql", m.Version, m.Name))
  		}
  	}

  	// Report duplicates
  	for v, count := range seen {
  		if count > 1 {
  			errs = append(errs, fmt.Sprintf("duplicate version %d appears %d times", v, count))
  		}
  	}

  	// Check sequential numbering starting from 1 (no gaps)
  	// Build unique sorted versions
  	versions := make([]int, 0, len(seen))
  	for v := range seen {
  		versions = append(versions, v)
  	}
  	sort.Ints(versions)

  	if len(versions) > 0 && versions[0] != 1 {
  		errs = append(errs, fmt.Sprintf("versions must start at 1, got %d", versions[0]))
  	}

  	for i := 1; i < len(versions); i++ {
  		expected := versions[i-1] + 1
  		if versions[i] != expected {
  			errs = append(errs, fmt.Sprintf("gap in versions: missing %d (have %d then %d)", expected, versions[i-1], versions[i]))
  		}
  	}

  	return errs
  }

params_schema:
  {"params":[{"name":"migrations","desc":"slice de Migration a validar, puede estar desordenado"}],"output":"slice de strings con mensajes de error; slice vacio si todas las migraciones son validas"}


id: nordvpn_container_run_go_infra
name: nordvpn_container_run
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error)
tags: ["vpn","nordvpn","docker","container","run","infra","network"]
uses_functions: ["docker_run_container_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/nordvpn_container_run.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: fb5d1b6d4bbb8307f8cda0b3bee91f7abd05cce404f3a0321613702b77713ac0
description:
  Ejecuta un container Docker cuyo trafico pasa por el gateway NordVPN usando --network=container:<gateway>. El container hereda la IP y tunel VPN del gateway.

example:
  ```go
  // Verificar IP desde VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "curlimages/curl",
      Cmd:     []string{"https://api.ipify.org"},
      Remove:  true,
      Gateway: "nordvpn",
  })

  // Ejecutar scraper bajo VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "my-scraper:latest",
      Env:     map[string]string{"TARGET_URL": "https://example.com"},
      Volumes: []string{"/tmp/output:/output"},
      Detach:  true,
      Name:    "scraper-vpn",
  })

  // Navegador headless bajo VPN
  id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
      Image:   "chromedp/headless-shell",
      Detach:  true,
      Name:    "chrome-vpn",
  })
  ```

notes:
  Requiere que el container gateway NordVPN este corriendo (usar `NordVPNContainerStart` primero). El container cliente no necesita capabilities especiales — hereda la red del gateway. Con `--network=container:X` el container no puede exponer puertos propios; los puertos deben mapearse en el gateway.

code:
  package infra

  import (
  	"fmt"
  )

  // NordVPNContainerRunOpts opciones para ejecutar un container a traves del gateway NordVPN.
  type NordVPNContainerRunOpts struct {
  	Image   string            // Imagen Docker a ejecutar (obligatorio)
  	Cmd     []string          // Comando a ejecutar en el container
  	Env     map[string]string // Variables de entorno
  	Volumes []string          // Bind mounts
  	Name    string            // Nombre del container (opcional)
  	Gateway string            // Nombre del container NordVPN gateway (default: "nordvpn")
  	Detach  bool              // Ejecutar en background
  	Remove  bool              // Eliminar al terminar (--rm)
  }

  // NordVPNContainerRun ejecuta un container Docker cuyo trafico de red
  // pasa por el container gateway NordVPN usando --network=container:<gateway>.
  // Devuelve el ID del container creado.
  func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error) {
  	if opts.Image == "" {
  		return "", fmt.Errorf("image required")
  	}
  	if opts.Gateway == "" {
  		opts.Gateway = "nordvpn"
  	}

  	id, err := DockerRunContainer(opts.Image, DockerRunOpts{
  		Name:    opts.Name,
  		Env:     opts.Env,
  		Volumes: opts.Volumes,
  		Detach:  opts.Detach,
  		Remove:  opts.Remove,
  		Network: "container:" + opts.Gateway,
  		Cmd:     opts.Cmd,
  	})
  	if err != nil {
  		return "", fmt.Errorf("nordvpn container run %s: %w", opts.Image, err)
  	}

  	return id, nil
  }

params_schema:
  {"params":[{"name":"opts","desc":"estructura NordVPNContainerRunOpts con Image, Cmd, Env, Volumes, Detach, Name, Gateway"}],"output":"ID del contenedor ejecutado bajo VPN"}


id: nordvpn_container_start_go_infra
name: nordvpn_container_start
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error)
tags: ["vpn","nordvpn","docker","container","gateway","infra","network"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","strings","time"]
tests: []
file_path: functions/infra/nordvpn_container_start.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 24548d4d425be87612deb564d9f77e20bcb1befccc2cd23bc09042367d725318
description:
  Levanta un container Docker con NordVPN como gateway de red. Otros containers pueden rutear su trafico a traves de este con --network=container:<name>. Espera hasta 30s a que el tunel este activo.

uses_functions:
  ["docker_run_container_go_infra","docker_remove_container_go_infra","docker_container_logs_go_infra"]

example:
  ```go
  id, err := NordVPNContainerStart(NordVPNContainerOpts{
      Token:   os.Getenv("NORDVPN_TOKEN"),
      Country: "Spain",
      City:    "Madrid",
  })
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("VPN gateway:", id)

  // Ahora otros containers pueden usar la VPN:
  // docker run --network=container:nordvpn curlimages/curl https://api.ipify.org
  ```

notes:
  Usa la imagen `ghcr.io/bubuntux/nordvpn`. Requiere un token de acceso NordVPN (obtener con `nordvpn token` desde CLI o desde la web de NordVPN). Limpia containers previos con el mismo nombre automaticamente. El protocolo por defecto es NordLynx (WireGuard).

code:
  package infra

  import (
  	"fmt"
  	"strings"
  	"time"
  )

  // NordVPNContainerOpts opciones para el container gateway NordVPN.
  type NordVPNContainerOpts struct {
  	Token    string // Token de acceso NordVPN (obligatorio)
  	Country  string // Pais al que conectar (opcional, ej: "Spain")
  	City     string // Ciudad (opcional, ej: "Madrid")
  	Protocol string // "NordLynx" o "OpenVPN" (default: NordLynx)
  	Name     string // Nombre del container (default: "nordvpn")
  }

  // NordVPNContainerStart levanta un container Docker con NordVPN como gateway.
  // Otros containers pueden usar su red con --network=container:<name>.
  // Espera hasta que el tunel este activo o timeout de 30s.
  func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) {
  	if opts.Token == "" {
  		return "", fmt.Errorf("nordvpn token required")
  	}
  	if opts.Name == "" {
  		opts.Name = "nordvpn"
  	}
  	if opts.Protocol == "" {
  		opts.Protocol = "NordLynx"
  	}

  	env := map[string]string{
  		"TOKEN":      opts.Token,
  		"TECHNOLOGY": opts.Protocol,
  	}
  	if opts.Country != "" {
  		connect := opts.Country
  		if opts.City != "" {
  			connect += " " + opts.City
  		}
  		env["CONNECT"] = connect
  	}

  	// Limpiar container previo con el mismo nombre si existe
  	_ = DockerRemoveContainer(opts.Name, true)

  	id, err := DockerRunContainer("ghcr.io/bubuntux/nordvpn", DockerRunOpts{
  		Name:   opts.Name,
  		Env:    env,
  		Detach: true,
  		CapAdd: []string{"NET_ADMIN", "NET_RAW"},
  	})
  	if err != nil {
  		return "", fmt.Errorf("nordvpn container start: %w", err)
  	}

  	// Esperar a que el tunel este activo
  	for i := 0; i < 30; i++ {
  		time.Sleep(1 * time.Second)
  		logs, logErr := DockerContainerLogs(opts.Name, 20)
  		if logErr != nil {
  			continue
  		}
  		if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") {
  			return id, nil
  		}
  		if strings.Contains(logs, "error") || strings.Contains(logs, "failed") {
  			return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name)
  		}
  	}

  	return id, fmt.Errorf("nordvpn connection timeout after 30s, check logs: docker logs %s", opts.Name)
  }

params_schema:
  {"params":[{"name":"opts","desc":"estructura NordVPNContainerOpts con Token, Country, City, Protocol"}],"output":"ID del contenedor gateway NordVPN"}


id: nordvpn_container_stop_go_infra
name: nordvpn_container_stop
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NordVPNContainerStop(gateway string, clientNames ...string) error
tags: ["vpn","nordvpn","docker","container","stop","cleanup","infra"]
uses_functions: ["docker_stop_container_go_infra","docker_remove_container_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/nordvpn_container_stop.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 39308369eee7a000ad54f0769a8c935ee29b656ff105ea69b0c49114f8d7a818
description:
  Detiene y elimina el container gateway NordVPN y opcionalmente los containers cliente que usan su red.

example:
  ```go
  // Parar solo el gateway
  err := NordVPNContainerStop("nordvpn")

  // Parar gateway y clientes asociados
  err := NordVPNContainerStop("nordvpn", "chrome-vpn", "scraper-vpn")
  ```

notes:
  Detiene primero los containers cliente (si se proporcionan) y luego el gateway. Importante: si los clientes usan `--network=container:nordvpn`, deben pararse antes que el gateway para evitar errores de red. Los clientes se paran con timeout de 5s, el gateway con 10s.

code:
  package infra

  import (
  	"fmt"
  )

  // NordVPNContainerStop detiene y elimina el container gateway NordVPN.
  // Tambien detiene containers que usen su red si se proporcionan.
  func NordVPNContainerStop(gateway string, clientNames ...string) error {
  	if gateway == "" {
  		gateway = "nordvpn"
  	}

  	// Primero parar los clientes que usan la red del gateway
  	for _, name := range clientNames {
  		_ = DockerStopContainer(name, 5)
  		_ = DockerRemoveContainer(name, true)
  	}

  	// Parar y eliminar el gateway
  	if err := DockerStopContainer(gateway, 10); err != nil {
  		return fmt.Errorf("nordvpn container stop: %w", err)
  	}
  	if err := DockerRemoveContainer(gateway, true); err != nil {
  		return fmt.Errorf("nordvpn container remove: %w", err)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"gateway","desc":"nombre del contenedor gateway NordVPN a detener"},{"name":"clientNames","desc":"nombres opcionales de containers cliente que usan la VPN (se detienen primero)"}],"output":"nil si se detuvieron exitosamente, error en caso contrario"}


id: parse_nordvpn_status_go_infra
name: parse_nordvpn_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func ParseNordVPNStatus(output string) NordVPNStatus
tags: ["vpn","nordvpn","parser","pure","infra"]
uses_functions: []
uses_types: ["NordVPNStatus_go_infra"]
returns: []
imports: ["regexp","strings"]
test_file_path: functions/infra/parse_nordvpn_status_test.go
file_path: functions/infra/parse_nordvpn_status.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 66fcea0ec30a196a937dcdbf7df69184271222baf8311dab0b8d66f815f47569
description:
  Parsea la salida de texto de nordvpn status a un struct tipado. Elimina codigos ANSI y normaliza claves.

example:
  ```go
  output := `Status: Connected
  Hostname: es42.nordvpn.com
  IP: 185.230.124.42
  Country: Spain
  City: Madrid
  Current Technology: NordLynx`

  s := ParseNordVPNStatus(output)
  // s.Connected == true
  // s.Hostname == "es42.nordvpn.com"
  // s.Country == "Spain"
  ```

tests:
  ["TestParseNordVPNStatus_Connected","TestParseNordVPNStatus_Disconnected","TestParseNordVPNStatus_WithANSI"]

notes:
  Funcion pura — no ejecuta comandos ni accede a red. Maneja codigos ANSI que NordVPN CLI emite en terminal. Campos no presentes en la salida quedan como zero value.

code:
  package infra

  import (
  	"regexp"
  	"strings"
  )

  // NordVPNStatus representa el estado parseado de nordvpn status.
  type NordVPNStatus struct {
  	Connected  bool   // true si hay conexion activa
  	Status     string // "Connected" o "Disconnected"
  	Hostname   string // ej: "es42.nordvpn.com"
  	IP         string // IP del servidor VPN
  	Country    string // ej: "Spain"
  	City       string // ej: "Madrid"
  	Technology string // ej: "NordLynx"
  	Protocol   string // ej: "nordlynx"
  	Transfer   string // ej: "1.2 MiB received, 500 KiB sent"
  	Uptime     string // ej: "5 minutes 32 seconds"
  }

  // ansiRegexp elimina codigos de escape ANSI.
  var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)

  // ParseNordVPNStatus parsea la salida de texto de `nordvpn status`
  // a un struct tipado. Funcion pura — no ejecuta comandos.
  func ParseNordVPNStatus(output string) NordVPNStatus {
  	var s NordVPNStatus

  	lines := strings.Split(output, "\n")
  	for _, line := range lines {
  		line = ansiRegexp.ReplaceAllString(line, "")
  		line = strings.TrimSpace(line)
  		line = strings.TrimLeft(line, "- ")

  		idx := strings.Index(line, ":")
  		if idx < 0 {
  			continue
  		}

  		key := strings.ToLower(strings.TrimSpace(line[:idx]))
  		val := strings.TrimSpace(line[idx+1:])

  		switch key {
  		case "status":
  			s.Status = val
  			s.Connected = strings.EqualFold(val, "connected")
  		case "hostname", "server":
  			s.Hostname = val
  		case "ip":
  			s.IP = val
  		case "country":
  			s.Country = val
  		case "city":
  			s.City = val
  		case "current technology":
  			s.Technology = val
  		case "current protocol":
  			s.Protocol = val
  		case "transfer":
  			s.Transfer = val
  		case "uptime":
  			s.Uptime = val
  		}
  	}

  	return s
  }

params_schema:
  {"params":[{"name":"output","desc":"salida de texto de nordvpn status"}],"output":"struct NordVPNStatus con campos Connected, Hostname, IP, Country, City, Technology"}


id: pdf_simple_report_go_infra
name: pdf_simple_report
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error)
tags: ["pdf","report","go","stub","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/pdf_simple_report.go
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: a1abd99740640a084e80675f78aefa6e429387fe9c5946b0b19eb13cf8b5b46d
description:
  Genera un PDF simple con titulo, secciones de texto y tablas opcionales. Stub activo: requiere go-pdf/fpdf que no esta en el modulo. Para PDFs desde Go añadir la dependencia; para casos complejos usar el builder Python.

example:
  ```go
  sections := []infra.ReportSection{
      {
          Title: "Estado del Registry",
          Text:  "El registry contiene 142 funciones, 38 tipos y 12 proposals pendientes.",
      },
      {
          Title: "Funciones por Dominio",
          Table: &infra.ReportTable{
              Headers: []string{"Dominio", "Funciones", "Tipos"},
              Rows: [][]string{
                  {"core", "45", "12"},
                  {"infra", "38", "8"},
                  {"finance", "22", "6"},
              },
          },
      },
  }

  path, err := infra.PdfSimpleReport("Registry Status Report", sections, "registry_report.pdf")
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("PDF generado:", path)
  ```

notes:
  Stub activo: retorna `fmt.Errorf("not implemented: ...")` hasta que `go-pdf/fpdf` se añada al modulo. No añadida como dependencia directa porque solo la usa esta funcion y añade peso al modulo. Para generacion de PDFs desde el registry, la solucion recomendada es el builder Python (`pdf_create_py_infra` + `pdf_add_text_py_infra` + `pdf_add_table_py_infra` + `pdf_save_py_infra`) que es mas completo y no requiere dependencias adicionales en Go.

documentation:
  ## Tipos auxiliares

  ```go
  type ReportSection struct {
      Title string
      Text  string
      Table *ReportTable  // nil = sin tabla en esta seccion
  }

  type ReportTable struct {
      Headers []string
      Rows    [][]string
  }
  ```

  ## Activar la implementacion real

  ```bash
  # Añadir dependencia al modulo Go
  go get github.com/go-pdf/fpdf

  # La funcion usa la API de go-pdf/fpdf:
  # pdf := fpdf.New("P", "mm", "A4", "")
  # pdf.AddPage()
  # pdf.SetFont("Arial", "B", 16)
  # pdf.Cell(0, 10, title)
  # ... para cada seccion y tabla
  # pdf.OutputFileAndClose(outputPath)
  ```

code:
  package infra

  import "fmt"

  // ReportSection es una seccion de un reporte PDF simple.
  // Contiene un titulo, texto descriptivo y una tabla opcional.
  type ReportSection struct {
  	Title string
  	Text  string
  	Table *ReportTable
  }

  // ReportTable es una tabla opcional dentro de una seccion de reporte.
  type ReportTable struct {
  	Headers []string
  	Rows    [][]string
  }

  // PdfSimpleReport genera un PDF simple con titulo, secciones y tablas opcionales.
  //
  // Stub: requiere go-pdf/fpdf que no esta en el modulo. Para generar PDFs
  // desde Go usar la alternativa Python (pdf_create_py_infra + pdf_add_table_py_infra
  // + pdf_save_py_infra) o añadir go-pdf/fpdf al go.mod.
  //
  // Para usar la implementacion real:
  //
  //	go get github.com/go-pdf/fpdf
  //
  // y reemplazar el cuerpo de esta funcion con la implementacion completa.
  func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error) {
  	return "", fmt.Errorf(
  		"not implemented: pdf_simple_report requires go-pdf/fpdf. "+
  			"Add it with: go get github.com/go-pdf/fpdf. "+
  			"Alternatively use pdf_create_py_infra + pdf_add_table_py_infra + pdf_save_py_infra for Python",
  	)
  }

params_schema:
  {"params":[{"name":"title","desc":"titulo principal del reporte PDF, aparece en la cabecera del documento"},{"name":"sections","desc":"lista de ReportSection, cada una con Title (string), Text (string) y Table opcional (*ReportTable con Headers y Rows)"},{"name":"outputPath","desc":"ruta del archivo PDF a generar en disco"}],"output":"outputPath con el PDF generado, o string vacio + error si la dependencia no esta disponible"}


id: postgres_open_go_infra
name: postgres_open
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
tags: ["database","postgres","postgresql","connection","sql"]
uses_functions: []
uses_types: ["db_config_go_infra"]
returns: []
error_type: error_go_core
imports: ["database/sql","github.com/jackc/pgx/v5/stdlib"]
tests: []
file_path: functions/infra/postgres_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5f7fabadeb4da54bd153b0e5650385a6b17ee0c35c0b237fdbc5b17ddd190c01
signature:
  func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error)

description:
  Conecta a PostgreSQL construyendo el DSN desde parametros individuales. sslmode por defecto 'disable' si vacio.

example:
  ```go
  db, err := PostgresOpen("localhost", 5432, "user", "secret", "mydb", "disable")
  if err != nil {
      log.Fatal(err)
  }
  defer DBClose(db)

  rows, err := DBQuery(db, "SELECT id, name FROM users WHERE active = $1", true)
  ```

notes:
  Usa el driver `github.com/jackc/pgx/v5/stdlib` registrado como "pgx". Construye DSN con los parametros separados para mayor legibilidad. Para produccion usar `sslmode=require` o `sslmode=verify-full`. Hace ping al abrir para verificar conectividad.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"

  	_ "github.com/jackc/pgx/v5/stdlib"
  )

  // PostgresOpen connects to a PostgreSQL server and returns a *sql.DB.
  // sslmode defaults to "disable" when empty.
  // Constructs a DSN of the form:
  //
  //	host=<host> port=<port> user=<user> password=<password> dbname=<dbname> sslmode=<sslmode>
  func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error) {
  	if sslmode == "" {
  		sslmode = "disable"
  	}
  	dsn := fmt.Sprintf(
  		"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
  		host, port, user, password, dbname, sslmode,
  	)
  	db, err := sql.Open("pgx", dsn)
  	if err != nil {
  		return nil, fmt.Errorf("postgres_open: open: %w", err)
  	}
  	if err := db.Ping(); err != nil {
  		db.Close()
  		return nil, fmt.Errorf("postgres_open: ping %s:%d/%s: %w", host, port, dbname, err)
  	}
  	return db, nil
  }

params_schema:
  {"params":[{"name":"host","desc":"hostname del servidor PostgreSQL"},{"name":"port","desc":"puerto del servidor (5432 por defecto)"},{"name":"user","desc":"nombre de usuario para autenticacion"},{"name":"password","desc":"contraseña para autenticacion"},{"name":"dbname","desc":"nombre de la base de datos"},{"name":"sslmode","desc":"modo SSL (disable, require, verify-full o vacio para disable)"}],"output":"conexion sql.DB abierta a PostgreSQL"}


id: process_kill_go_infra
name: process_kill
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessKill(handle *ProcessHandle, graceSec int) error
tags: ["process","subprocess","kill","signal","sigterm","sigkill","infra"]
uses_functions: []
uses_types: ["process_handle_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","syscall","time"]
tests: ["kill process"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_kill.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: 98f0c1be85c00223abb7006ccd401f3439289a791cf540e2c03993a746ce5075
description:
  Termina un subproceso enviando SIGTERM al process group. Espera hasta graceSec segundos a que el proceso muera voluntariamente. Si sigue vivo, envia SIGKILL. Retorna error solo si la senal no pudo entregarse.

example:
  ```go
  h, err := ProcessSpawn("sleep 300", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  // Dar 3 segundos de gracia antes de SIGKILL
  if err := ProcessKill(h, 3); err != nil {
      log.Printf("kill failed: %v", err)
  }
  ```

notes:
  Funcion impura: envia senales al sistema operativo. Usa -handle.Pid (negativo) para direccionar el process group completo, matando tanto al proceso principal como a sus hijos. ESRCH se ignora porque significa que el proceso ya murio, lo cual es el objetivo deseado. Comprueba si el proceso sigue vivo con signal 0 (kill -0) cada 100ms durante el grace period.

code:
  package infra

  import (
  	"fmt"
  	"syscall"
  	"time"
  )

  // ProcessKill sends SIGTERM to the process group of handle, then waits up to
  // graceSec seconds for the process to exit. If it is still alive after the
  // grace period, SIGKILL is sent. Returns an error only if the signal could not
  // be delivered (e.g. the process group does not exist).
  func ProcessKill(handle *ProcessHandle, graceSec int) error {
  	// Send SIGTERM to the process group (negative pid targets the group).
  	if err := syscall.Kill(-handle.Pid, syscall.SIGTERM); err != nil {
  		// ESRCH means the process is already gone — not an error from our view.
  		if err != syscall.ESRCH {
  			return fmt.Errorf("process_kill: sigterm: %w", err)
  		}
  		return nil
  	}

  	// Poll until the process exits or the grace period expires.
  	deadline := time.Now().Add(time.Duration(graceSec) * time.Second)
  	for time.Now().Before(deadline) {
  		// Check if process has exited by sending signal 0 (no-op).
  		err := syscall.Kill(-handle.Pid, 0)
  		if err == syscall.ESRCH {
  			// Process group is gone.
  			return nil
  		}
  		time.Sleep(100 * time.Millisecond)
  	}

  	// Still alive after grace period — escalate to SIGKILL.
  	if err := syscall.Kill(-handle.Pid, syscall.SIGKILL); err != nil {
  		if err != syscall.ESRCH {
  			return fmt.Errorf("process_kill: sigkill: %w", err)
  		}
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"handle","desc":"handle del proceso lanzado por ProcessSpawn"},{"name":"graceSec","desc":"segundos de gracia entre SIGTERM y SIGKILL; 0 envia SIGKILL inmediatamente"}],"output":"nil si el proceso fue terminado correctamente; error si la senal no pudo entregarse"}


id: process_spawn_go_infra
name: process_spawn
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessSpawn(command string, dir string, env []string, shell string) (*ProcessHandle, error)
tags: ["process","subprocess","spawn","exec","shell","infra"]
uses_functions: []
uses_types: ["process_handle_go_infra"]
returns: ["process_handle_go_infra"]
error_type: error_go_core
imports: ["fmt","os","os/exec","strings","syscall","time"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_spawn.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: 6cdf1b1a7aef933452182f6090e2e1e10c935fda7e2563518b9169d2c4e33fa1
description:
  Lanza un subproceso usando el shell indicado. Si shell esta vacio usa 'sh'. Comandos con newlines se tratan como scripts multilinea (se escriben a un archivo temporal). Configura un process group propio (Setpgid) para poder matar todos los hijos con ProcessKill. Captura stdout y stderr via pipes.

example:
  ```go
  h, err := ProcessSpawn("echo hello", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  res, err := ProcessWait(h, 10)
  fmt.Println(res.Stdout) // "hello\n"
  ```

tests:
  ["spawn and wait echo","spawn with timeout kills","spawn with env","spawn script","spawn with working dir","kill process"]

notes:
  Funcion impura: hace I/O (crea archivo temporal para scripts, lanza proceso). El process group (Setpgid=true) permite a ProcessKill enviar senales al grupo completo con -Pid, afectando a todos los hijos del proceso lanzado. Para scripts multilinea el archivo temporal queda en el directorio temporal del OS y no se limpia automaticamente.

code:
  package infra

  import (
  	"bytes"
  	"fmt"
  	"os"
  	"os/exec"
  	"strings"
  	"syscall"
  	"time"
  )

  // ProcessSpawn launches a subprocess using the given shell.
  // If shell is empty, "sh" is used. If command contains newlines it is treated
  // as a multi-line script: the content is written to a temp file and executed
  // with `shell <tempfile>`. Otherwise it is executed with `shell -c <command>`.
  // dir sets the working directory (empty = inherit). env sets the environment
  // (nil = inherit parent env). The process group is created with Setpgid so
  // that ProcessKill can target the whole group.
  func ProcessSpawn(command string, dir string, env []string, shell string) (*ProcessHandle, error) {
  	if shell == "" {
  		shell = "sh"
  	}

  	var cmd *exec.Cmd

  	if strings.Contains(command, "\n") {
  		// Multi-line script: write to a temp file and execute it.
  		tmp, err := os.CreateTemp("", "fn-proc-*.sh")
  		if err != nil {
  			return nil, fmt.Errorf("process_spawn: create temp file: %w", err)
  		}
  		if _, err := tmp.WriteString(command); err != nil {
  			_ = os.Remove(tmp.Name())
  			return nil, fmt.Errorf("process_spawn: write temp file: %w", err)
  		}
  		if err := tmp.Close(); err != nil {
  			_ = os.Remove(tmp.Name())
  			return nil, fmt.Errorf("process_spawn: close temp file: %w", err)
  		}
  		cmd = exec.Command(shell, tmp.Name())
  	} else {
  		cmd = exec.Command(shell, "-c", command)
  	}

  	if dir != "" {
  		cmd.Dir = dir
  	}
  	if len(env) > 0 {
  		cmd.Env = env
  	}

  	// New process group so we can kill all children as a group.
  	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

  	// Use buffers instead of pipes to avoid race between Wait() and ReadAll().
  	var stdoutBuf, stderrBuf bytes.Buffer
  	cmd.Stdout = &stdoutBuf
  	cmd.Stderr = &stderrBuf

  	start := time.Now()
  	if err := cmd.Start(); err != nil {
  		return nil, fmt.Errorf("process_spawn: start: %w", err)
  	}

  	return &ProcessHandle{
  		Cmd:       cmd,
  		Pid:       cmd.Process.Pid,
  		StartTime: start,
  		Dir:       dir,
  		stdout:    &stdoutBuf,
  		stderr:    &stderrBuf,
  	}, nil
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar; si contiene newlines se trata como script multilinea"},{"name":"dir","desc":"directorio de trabajo del proceso hijo; vacio hereda el del proceso padre"},{"name":"env","desc":"variables de entorno en formato KEY=VALUE; nil hereda el entorno del proceso padre"},{"name":"shell","desc":"interprete shell a usar (sh, bash, zsh); vacio usa 'sh'"}],"output":"handle del proceso lanzado con Cmd, Pid, StartTime, Dir y los pipes de I/O"}


id: process_wait_go_infra
name: process_wait
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func ProcessWait(handle *ProcessHandle, timeoutSec int) (ProcessResult, error)
tags: ["process","subprocess","wait","timeout","exec","infra"]
uses_functions: ["process_kill_go_infra"]
uses_types: ["process_handle_go_infra","process_result_go_infra"]
returns: ["process_result_go_infra"]
error_type: error_go_core
imports: ["fmt","io","time"]
test_file_path: functions/infra/process_spawn_test.go
file_path: functions/infra/process_wait.go
created_at: 2026-04-12T11:03:11Z
updated_at: 2026-04-12T11:03:11Z
props: []
emits: []
variant: []
content_hash: e6730640f1fad474b59cbaffc963a7842dd5f8dcfb033fe97488031986b2dbe5
description:
  Espera a que un subproceso termine y recopila su salida. Lee stdout y stderr completos en goroutines para evitar deadlocks en pipes. Si timeoutSec > 0 y el proceso no termina en ese tiempo, llama a ProcessKill y marca el resultado con Killed=true. Retorna el exit code, salida completa y duracion total.

example:
  ```go
  h, err := ProcessSpawn("sleep 60", "", nil, "")
  if err != nil {
      log.Fatal(err)
  }
  res, err := ProcessWait(h, 5) // timeout de 5 segundos
  if res.Killed {
      fmt.Println("proceso terminado por timeout")
  }
  ```

tests:
  ["spawn and wait echo","spawn with timeout kills","spawn with env","spawn script","spawn with working dir"]

notes:
  Funcion impura: bloquea esperando I/O y posiblemente llama a ProcessKill. Lee stdout y stderr en goroutines separadas antes de llamar a cmd.Wait() para evitar el deadlock clasico donde cmd.Wait() bloquea porque los pipes estan llenos y nadie los lee. El exit code -1 indica que ProcessState no estaba disponible (proceso matado antes de registrar estado).

code:
  package infra

  import (
  	"time"
  )

  // ProcessWait waits for a subprocess to finish and collects its output.
  // If timeoutSec > 0 and the process has not exited by then, ProcessKill is
  // called with graceSec=5 and the result is marked Killed=true.
  func ProcessWait(handle *ProcessHandle, timeoutSec int) (ProcessResult, error) {
  	// Wait for the process in a goroutine.
  	waitCh := make(chan error, 1)
  	go func() {
  		waitCh <- handle.Cmd.Wait()
  	}()

  	killed := false

  	if timeoutSec > 0 {
  		timer := time.NewTimer(time.Duration(timeoutSec) * time.Second)
  		defer timer.Stop()
  		select {
  		case <-timer.C:
  			// Timeout exceeded — kill the process group.
  			_ = ProcessKill(handle, 5)
  			killed = true
  			<-waitCh
  		case <-waitCh:
  		}
  	} else {
  		<-waitCh
  	}

  	// After Wait() returns, buffers are safe to read.
  	exitCode := 0
  	if handle.Cmd.ProcessState != nil {
  		exitCode = handle.Cmd.ProcessState.ExitCode()
  	} else if killed {
  		exitCode = -1
  	}

  	duration := time.Since(handle.StartTime)

  	return ProcessResult{
  		ExitCode:   exitCode,
  		Stdout:     handle.stdout.String(),
  		Stderr:     handle.stderr.String(),
  		DurationMs: duration.Milliseconds(),
  		Killed:     killed,
  	}, nil
  }

params_schema:
  {"params":[{"name":"handle","desc":"handle del proceso lanzado por ProcessSpawn"},{"name":"timeoutSec","desc":"segundos maximos de espera; 0 o negativo espera indefinidamente"}],"output":"resultado con exit code, stdout, stderr, duracion en ms y flag de killed"}


id: scaffold_wails_app_go_infra
name: scaffold_wails_app
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error
tags: ["wails","scaffold","desktop","project","generator"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","os/exec","path/filepath","text/template","fmt","strings","context"]
tests: []
file_path: functions/infra/scaffold_wails_app.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 2afe52916ed12a7a8a4c391df2fc2591c9bfeb90a4da8edffb623a33013f124d
description:
  Genera proyecto Wails completo: main.go con embed, app.go con bindings base, wails.json, go.mod, y frontend vinculado a Frontend_Library.

example:
  ```go
  ScaffoldWailsApp(ctx, ScaffoldWailsAppConfig{
      Name:   "my-dashboard",
      Dir:    "/home/user/projects/my-dashboard",
      Title:  "My Dashboard",
      Width:  1280,
      Height: 800,
      Author: "Lucas",
  })
  ```

notes:
  Pipeline que compone: generación de templates Go + creación de frontend via create-project.sh de Frontend_Library + go mod tidy. Incluye GenerateAppBinding para crear métodos de binding programáticamente.

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"os"
  	"os/exec"
  	"path/filepath"
  	"strings"
  	"text/template"
  )

  // ScaffoldWailsAppConfig configura la generación del proyecto Wails.
  type ScaffoldWailsAppConfig struct {
  	Name        string // Nombre del proyecto
  	Dir         string // Directorio destino
  	Title       string // Título de la ventana
  	Width       int    // Ancho de la ventana (default 1024)
  	Height      int    // Alto de la ventana (default 768)
  	Author      string // Nombre del autor
  	FrontendLib string // Path a la frontend library (default ~/.local_agentes/frontend/frontend)
  }

  const mainGoTpl = `package main

  import (
  	"embed"

  	"github.com/wailsapp/wails/v2"
  	"github.com/wailsapp/wails/v2/pkg/options"
  	"github.com/wailsapp/wails/v2/pkg/options/assetserver"
  )

  //go:embed all:frontend/dist
  var assets embed.FS

  func main() {
  	app := NewApp()

  	err := wails.Run(&options.App{
  		Title:  "{{.Title}}",
  		Width:  {{.Width}},
  		Height: {{.Height}},
  		AssetServer: &assetserver.Options{
  			Assets: assets,
  		},
  		BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
  		OnStartup:        app.startup,
  		Bind: []interface{}{
  			app,
  		},
  	})

  	if err != nil {
  		println("Error:", err.Error())
  	}
  }
  `

  const appGoTpl = `package main

  import (
  	"context"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // App struct — cada método público se expone como binding IPC al frontend.
  type App struct {
  	ctx context.Context
  }

  func NewApp() *App {
  	return &App{}
  }

  func (a *App) startup(ctx context.Context) {
  	a.ctx = ctx
  }

  // EmitEvent emite un evento al frontend.
  func (a *App) EmitEvent(eventName string, data interface{}) {
  	runtime.EventsEmit(a.ctx, eventName, data)
  }

  // Ping verifica que el IPC funciona.
  func (a *App) Ping() string {
  	return "pong"
  }
  `

  const wailsJSONTpl = `{
    "$schema": "https://wails.io/schemas/config.v2.json",
    "name": "{{.Name}}",
    "outputfilename": "{{.Name}}",
    "frontend:dir": "./frontend",
    "frontend:install": "pnpm install",
    "frontend:build": "pnpm run build",
    "frontend:dev:watcher": "pnpm run dev",
    "frontend:dev:serverUrl": "auto",
    "wailsjsdir": "./frontend/src/wailsjs",
    "author": {
      "name": "{{.Author}}"
    }
  }
  `

  const goModTpl = `module {{.Name}}

  go 1.23

  require github.com/wailsapp/wails/v2 v2.11.0
  `

  // ScaffoldWailsApp genera la estructura base de un proyecto Wails con frontend vinculado.
  func ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error {
  	if cfg.Name == "" {
  		return fmt.Errorf("name is required")
  	}
  	if cfg.Dir == "" {
  		cfg.Dir = cfg.Name
  	}
  	if cfg.Title == "" {
  		cfg.Title = cfg.Name
  	}
  	if cfg.Width == 0 {
  		cfg.Width = 1024
  	}
  	if cfg.Height == 0 {
  		cfg.Height = 768
  	}
  	if cfg.Author == "" {
  		cfg.Author = "Agent"
  	}
  	if cfg.FrontendLib == "" {
  		home, _ := os.UserHomeDir()
  		cfg.FrontendLib = filepath.Join(home, ".local_agentes", "frontend", "frontend")
  	}

  	// Crear directorio
  	if err := os.MkdirAll(cfg.Dir, 0755); err != nil {
  		return fmt.Errorf("creating dir: %w", err)
  	}

  	// Generar archivos Go
  	files := map[string]string{
  		"main.go":    mainGoTpl,
  		"app.go":     appGoTpl,
  		"wails.json": wailsJSONTpl,
  		"go.mod":     goModTpl,
  	}

  	for name, tpl := range files {
  		path := filepath.Join(cfg.Dir, name)
  		t, err := template.New(name).Parse(tpl)
  		if err != nil {
  			return fmt.Errorf("parsing template %s: %w", name, err)
  		}
  		f, err := os.Create(path)
  		if err != nil {
  			return fmt.Errorf("creating %s: %w", name, err)
  		}
  		if err := t.Execute(f, cfg); err != nil {
  			f.Close()
  			return fmt.Errorf("executing template %s: %w", name, err)
  		}
  		f.Close()
  	}

  	// Crear frontend como link simbólico o copiar template
  	frontendDir := filepath.Join(cfg.Dir, "frontend")
  	createProjectScript := filepath.Join(filepath.Dir(cfg.FrontendLib), "..", "scripts", "create-project.sh")

  	if _, err := os.Stat(createProjectScript); err == nil {
  		// Usar el script de Frontend_Library
  		cmd := exec.CommandContext(ctx, "bash", createProjectScript, cfg.Name, frontendDir, "--wails")
  		cmd.Stdout = os.Stdout
  		cmd.Stderr = os.Stderr
  		if err := cmd.Run(); err != nil {
  			return fmt.Errorf("creating frontend: %w", err)
  		}
  	} else {
  		// Fallback: crear frontend mínimo
  		if err := os.MkdirAll(filepath.Join(frontendDir, "src"), 0755); err != nil {
  			return fmt.Errorf("creating frontend dir: %w", err)
  		}
  		pkgJSON := fmt.Sprintf(`{"name":"%s-frontend","private":true,"scripts":{"dev":"vite","build":"vite build"}}`, cfg.Name)
  		os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(pkgJSON), 0644)
  	}

  	// go mod tidy
  	cmd := exec.CommandContext(ctx, "go", "mod", "tidy")
  	cmd.Dir = cfg.Dir
  	cmd.Stdout = os.Stdout
  	cmd.Stderr = os.Stderr
  	_ = cmd.Run() // No fatal si falla

  	fmt.Printf("Wails app scaffolded at %s\n", cfg.Dir)
  	return nil
  }

  // GenerateAppBinding genera el código Go para un método de binding.
  func GenerateAppBinding(name string, params []string, returnType string, body string) string {
  	var sb strings.Builder
  	sb.WriteString(fmt.Sprintf("func (a *App) %s(", name))
  	sb.WriteString(strings.Join(params, ", "))
  	sb.WriteString(")")
  	if returnType != "" {
  		sb.WriteString(" " + returnType)
  	}
  	sb.WriteString(" {\n")
  	sb.WriteString("\t" + body + "\n")
  	sb.WriteString("}\n")
  	return sb.String()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context para cancelacion"},{"name":"cfg","desc":"estructura ScaffoldWailsAppConfig con Name, Dir, Title, Width, Height, Author"}],"output":"nil si el scaffold fue exitoso, error en caso contrario"}


id: setup_vps_app_go_infra
name: setup_vps_app
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SetupVPSApp(conn SSHConn, cfg DeployConfig) error
tags: ["deploy","vps","setup","systemd","ssh","pipeline","infra"]
uses_types: ["ssh_conn_go_infra","DeployConfig_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/setup_vps_app.go
created_at: 2026-04-12T12:48:38Z
updated_at: 2026-04-12T12:48:38Z
props: []
emits: []
variant: []
content_hash: bb775514bc7ed1fc674cbca3ee681796eb7c3c85eb6400867b246ca998f8196c
description:
  Orquesta el setup inicial de una app en un VPS remoto: verifica SSH, crea dirs y usuario, sube binario, instala systemd unit y hace health check.

uses_functions:
  ["ssh_check_go_infra","vps_setup_app_go_infra","ssh_upload_go_infra","ssh_exec_go_infra","systemd_generate_unit_go_infra","systemd_install_go_infra","health_check_http_go_infra"]

example:
  ```go
  conn := SSHConn{Host: "185.x.x.x", User: "root"}
  cfg := DeployConfig{
      AppName:     "dag_engine",
      LocalDir:    "apps/dag_engine",
      RemoteDir:   "/opt/apps/dag_engine",
      BinaryName:  "dag_engine",
      ServiceUser: "deploy",
      Port:        8080,
      HealthPath:  "/api/health",
      Env:         map[string]string{"PORT": "8080"},
  }
  err := SetupVPSApp(conn, cfg)
  ```

notes:
  Pipeline de 6 pasos para primera instalación. Después del setup inicial, usar `deploy_app_remote` para deploys continuos (no regenera dirs ni systemd unit). El health check espera hasta 30 segundos con polling cada 2s.

code:
  package infra

  import "fmt"

  // SetupVPSApp orquesta el setup inicial de una app en un VPS remoto.
  // Pasos: verificar SSH → crear dirs/usuario → rsync código → generar unit → instalar systemd → health check.
  func SetupVPSApp(conn SSHConn, cfg DeployConfig) error {
  	// 1. Verificar conectividad SSH
  	if err := SSHCheck(conn); err != nil {
  		return fmt.Errorf("setup_vps_app: ssh check: %w", err)
  	}

  	// 2. Preparar directorios y usuario en el VPS
  	if err := VPSSetupApp(conn, cfg.AppName, cfg.RemoteDir, cfg.ServiceUser); err != nil {
  		return fmt.Errorf("setup_vps_app: vps setup: %w", err)
  	}

  	// 3. Subir binario o archivos de la app
  	if err := uploadAppFiles(conn, cfg); err != nil {
  		return fmt.Errorf("setup_vps_app: upload: %w", err)
  	}

  	// 4. Generar unit de systemd
  	execStart := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)
  	unit := SystemdGenerateUnit(cfg.AppName, execStart, cfg.RemoteDir, cfg.ServiceUser, cfg.Env)

  	// 5. Instalar y arrancar servicio
  	if err := SystemdInstall(conn, cfg.AppName, unit); err != nil {
  		return fmt.Errorf("setup_vps_app: systemd install: %w", err)
  	}

  	// 6. Health check (si hay endpoint configurado)
  	if cfg.HealthPath != "" && cfg.Port > 0 {
  		url := fmt.Sprintf("http://%s:%d%s", conn.Host, cfg.Port, cfg.HealthPath)
  		if err := HealthCheckHTTP(url, 30, 2000); err != nil {
  			return fmt.Errorf("setup_vps_app: health check: %w", err)
  		}
  	}

  	return nil
  }

  // uploadAppFiles sube el binario compilado al VPS via SCP.
  func uploadAppFiles(conn SSHConn, cfg DeployConfig) error {
  	localBinary := fmt.Sprintf("%s/%s", cfg.LocalDir, cfg.BinaryName)
  	remoteBinary := fmt.Sprintf("%s/%s", cfg.RemoteDir, cfg.BinaryName)

  	if err := SSHUpload(conn, localBinary, remoteBinary); err != nil {
  		return err
  	}

  	// Hacer ejecutable
  	_, stderr, code, err := SSHExec(conn, fmt.Sprintf("chmod +x %s", remoteBinary))
  	if err != nil {
  		return err
  	}
  	if code != 0 {
  		return fmt.Errorf("chmod: %s", stderr)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al VPS destino"},{"name":"cfg","desc":"configuración de deploy con nombre, rutas, build, puerto, env vars"}],"output":"nil si el setup completo fue exitoso"}


id: smtp_connect_go_infra
name: smtp_connect
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error)
tags: ["email","smtp","connection","tls","starttls","auth"]
uses_functions: []
uses_types: ["SMTPConfig_go_infra"]
returns: []
error_type: error_go_core
imports: ["crypto/tls","fmt","net","net/smtp"]
tests: ["conecta sin cifrado a servidor mock","error si el servidor no existe"]
test_file_path: functions/infra/smtp_connect_test.go
file_path: functions/infra/smtp_connect.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 8d43fd85fb2d0f291b4ea536dc228a7441bbe34f2f5edba92b72a0f52b2e7935
description:
  Establece una conexion SMTP autenticada. TLSMode 'tls' usa TLS directo (port 465), 'starttls' hace upgrade STARTTLS (port 587), '' sin cifrado (port 25). Retorna un *smtp.Client listo para usar con SMTPSend.

example:
  ```go
  cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "app-pw", TLSMode: "starttls"}
  client, err := SMTPConnect(cfg)
  if err != nil {
      log.Fatal(err)
  }
  defer client.Quit()
  // pasar client a SMTPSend(...)
  ```

notes:
  Funcion impura — abre conexion TCP real. El caller es responsable de cerrar el cliente con `client.Quit()`. Para test unitario usar un listener TCP local como mock. `SMTPSend` acepta el `*smtp.Client` retornado por esta funcion.

code:
  package infra

  import (
  	"crypto/tls"
  	"fmt"
  	"net"
  	"net/smtp"
  )

  // SMTPConnect establishes an authenticated SMTP connection using the given config.
  // TLSMode controls encryption:
  //   - "tls"      → direct TLS (typical port 465)
  //   - "starttls" → plain connection upgraded with STARTTLS (typical port 587)
  //   - ""         → no encryption (typical port 25)
  //
  // Returns an *smtp.Client ready to use with SMTPSend.
  // The caller is responsible for calling client.Quit() when done.
  func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error) {
  	addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)

  	switch cfg.TLSMode {
  	case "tls":
  		tlsCfg := &tls.Config{ServerName: cfg.Host}
  		conn, err := tls.Dial("tcp", addr, tlsCfg)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: tls dial %s: %w", addr, err)
  		}
  		client, err := smtp.NewClient(conn, cfg.Host)
  		if err != nil {
  			conn.Close()
  			return nil, fmt.Errorf("smtp_connect: new client: %w", err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil

  	case "starttls":
  		conn, err := net.Dial("tcp", addr)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err)
  		}
  		client, err := smtp.NewClient(conn, cfg.Host)
  		if err != nil {
  			conn.Close()
  			return nil, fmt.Errorf("smtp_connect: new client: %w", err)
  		}
  		tlsCfg := &tls.Config{ServerName: cfg.Host}
  		if err := client.StartTLS(tlsCfg); err != nil {
  			client.Close()
  			return nil, fmt.Errorf("smtp_connect: starttls: %w", err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil

  	default:
  		// no encryption
  		client, err := smtp.Dial(addr)
  		if err != nil {
  			return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err)
  		}
  		if cfg.Username != "" {
  			auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
  			if err := client.Auth(auth); err != nil {
  				client.Close()
  				return nil, fmt.Errorf("smtp_connect: auth: %w", err)
  			}
  		}
  		return client, nil
  	}
  }

params_schema:
  {"params":[{"name":"cfg","desc":"configuracion SMTP: host, port, usuario, password y modo TLS"}],"output":"*smtp.Client autenticado y listo para enviar mensajes; el caller debe llamar client.Quit() al terminar"}


id: smtp_send_go_infra
name: smtp_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SMTPSend(client *smtp.Client, msg EmailMessage) error
tags: ["email","smtp","send","mime","multipart"]
uses_functions: ["smtp_connect_go_infra"]
uses_types: ["EmailMessage_go_infra"]
returns: []
error_type: error_go_core
test_file_path: functions/infra/smtp_send_test.go
file_path: functions/infra/smtp_send.go
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: ec1bc52f76d305a2d241e80170f5ebd532aa6948e65c8ff3eda42e9095fc4c6f
description:
  Serializa un EmailMessage a MIME y lo envia usando el smtp.Client dado. Soporta texto plano, HTML, multipart/alternative y adjuntos (multipart/mixed).

imports:
  ["bytes","encoding/base64","fmt","mime","mime/multipart","net/smtp","net/textproto","strings","time"]

example:
  ```go
  cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "pw", TLSMode: "starttls"}
  client, err := SMTPConnect(cfg)
  if err != nil { log.Fatal(err) }
  defer client.Quit()

  msg := EmailBuildHTML("u@gmail.com", []string{"dest@example.com"}, "Asunto", "<b>Hola</b>")
  if err := SMTPSend(client, msg); err != nil { log.Fatal(err) }
  ```

tests:
  ["envia mensaje texto plano via mock smtp","envia mensaje html via mock smtp","envia con adjunto via mock smtp"]

notes:
  Funcion impura — envia datos por red. El cliente debe estar autenticado (obtenido de `SMTPConnect`). BCC se incluye en RCPT TO pero no en las cabeceras MIME visibles. Los adjuntos se codifican en base64. Para HTML+texto usa `multipart/alternative`; con adjuntos usa `multipart/mixed`.

code:
  package infra

  import (
  	"bytes"
  	"encoding/base64"
  	"fmt"
  	"mime"
  	"mime/multipart"
  	"net/smtp"
  	"net/textproto"
  	"strings"
  	"time"
  )

  // SMTPSend serializes msg to MIME and sends it using the given smtp.Client.
  // Supports plain text, HTML, and mixed (with attachments) content types.
  // The client must be authenticated (obtained from SMTPConnect).
  func SMTPSend(client *smtp.Client, msg EmailMessage) error {
  	if err := client.Mail(extractAddr(msg.From)); err != nil {
  		return fmt.Errorf("smtp_send: MAIL FROM: %w", err)
  	}

  	allTo := append(append([]string{}, msg.To...), msg.CC...)
  	allTo = append(allTo, msg.BCC...)
  	for _, addr := range allTo {
  		if err := client.Rcpt(extractAddr(addr)); err != nil {
  			return fmt.Errorf("smtp_send: RCPT TO %s: %w", addr, err)
  		}
  	}

  	w, err := client.Data()
  	if err != nil {
  		return fmt.Errorf("smtp_send: DATA: %w", err)
  	}
  	defer w.Close()

  	body, err := buildMIME(msg)
  	if err != nil {
  		return fmt.Errorf("smtp_send: build mime: %w", err)
  	}

  	if _, err := w.Write(body); err != nil {
  		return fmt.Errorf("smtp_send: write: %w", err)
  	}
  	return nil
  }

  // extractAddr extracts the bare email address from a possibly formatted string
  // like "Alice <alice@example.com>" → "alice@example.com".
  func extractAddr(addr string) string {
  	if i := strings.Index(addr, "<"); i >= 0 {
  		if j := strings.Index(addr[i:], ">"); j >= 0 {
  			return addr[i+1 : i+j]
  		}
  	}
  	return strings.TrimSpace(addr)
  }

  // buildMIME constructs the full MIME email bytes.
  func buildMIME(msg EmailMessage) ([]byte, error) {
  	var buf bytes.Buffer

  	// Headers
  	buf.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n")
  	buf.WriteString("From: " + msg.From + "\r\n")
  	buf.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
  	if len(msg.CC) > 0 {
  		buf.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n")
  	}
  	buf.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", msg.Subject) + "\r\n")
  	buf.WriteString("MIME-Version: 1.0\r\n")
  	for k, v := range msg.Headers {
  		buf.WriteString(k + ": " + v + "\r\n")
  	}

  	hasText := msg.BodyText != ""
  	hasHTML := msg.BodyHTML != ""
  	hasAtts := len(msg.Attachments) > 0

  	switch {
  	case hasAtts:
  		// multipart/mixed: text or html parts + attachments
  		mw := multipart.NewWriter(&buf)
  		buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mw.Boundary() + "\"\r\n\r\n")

  		if hasText && hasHTML {
  			// nested multipart/alternative inside mixed
  			var altBuf bytes.Buffer
  			aw := multipart.NewWriter(&altBuf)
  			writeTextPart(aw, msg.BodyText)
  			writeHTMLPart(aw, msg.BodyHTML)
  			aw.Close()
  			altHeader := textproto.MIMEHeader{}
  			altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+aw.Boundary()+"\"")
  			pw, _ := mw.CreatePart(altHeader)
  			pw.Write(altBuf.Bytes())
  		} else if hasHTML {
  			writeHTMLPart(mw, msg.BodyHTML)
  		} else {
  			writeTextPart(mw, msg.BodyText)
  		}

  		for _, att := range msg.Attachments {
  			writeAttachment(mw, att)
  		}
  		mw.Close()

  	case hasText && hasHTML:
  		mw := multipart.NewWriter(&buf)
  		buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + mw.Boundary() + "\"\r\n\r\n")
  		writeTextPart(mw, msg.BodyText)
  		writeHTMLPart(mw, msg.BodyHTML)
  		mw.Close()

  	case hasHTML:
  		buf.WriteString("Content-Type: text/html; charset=utf-8\r\n")
  		buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
  		buf.WriteString(msg.BodyHTML)

  	default:
  		buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
  		buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
  		buf.WriteString(msg.BodyText)
  	}

  	return buf.Bytes(), nil
  }

  func writeTextPart(mw *multipart.Writer, body string) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", "text/plain; charset=utf-8")
  	h.Set("Content-Transfer-Encoding", "quoted-printable")
  	pw, _ := mw.CreatePart(h)
  	pw.Write([]byte(body))
  }

  func writeHTMLPart(mw *multipart.Writer, body string) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", "text/html; charset=utf-8")
  	h.Set("Content-Transfer-Encoding", "quoted-printable")
  	pw, _ := mw.CreatePart(h)
  	pw.Write([]byte(body))
  }

  func writeAttachment(mw *multipart.Writer, att EmailAttachment) {
  	h := textproto.MIMEHeader{}
  	h.Set("Content-Type", att.ContentType)
  	h.Set("Content-Transfer-Encoding", "base64")
  	h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
  	pw, _ := mw.CreatePart(h)
  	enc := base64.NewEncoder(base64.StdEncoding, pw)
  	enc.Write(att.Data)
  	enc.Close()
  }

params_schema:
  {"params":[{"name":"client","desc":"cliente SMTP autenticado obtenido de SMTPConnect"},{"name":"msg","desc":"mensaje email con From, To, Subject y al menos un body (HTML o text)"}],"output":"nil si el envio fue exitoso; error con contexto si falla MAIL FROM, RCPT TO, DATA o la escritura MIME"}


id: sqlite_open_go_infra
name: sqlite_open
kind: function
lang: go
domain: infra
version: 1.1.0
purity: impure
signature: func SQLiteOpen(path string, basePath string) (*sql.DB, error)
tags: ["database","sqlite","connection","sql"]
uses_functions: []
uses_types: ["db_config_go_infra"]
returns: []
error_type: error_go_core
imports: ["database/sql","path/filepath","github.com/mattn/go-sqlite3"]
tests: []
file_path: functions/infra/sqlite_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b5a71fb204400c6c90673bbb58b9c93a9648128ba52eac07bb097ad941862f34
description:
  Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion. Si basePath es no-vacio y path es relativo, resuelve el path como filepath.Join(basePath, path).

example:
  ```go
  // Path absoluto o relativo al cwd
  db, err := SQLiteOpen("/data/myapp.db", "")
  if err != nil {
      log.Fatal(err)
  }
  defer DBClose(db)

  // Path relativo al directorio del archivo YAML de configuracion
  configDir := filepath.Dir(configPath)
  db, err := SQLiteOpen(cfg.DatabasePath, configDir)
  if err != nil {
      log.Fatal(err)
  }

  rows, err := DBQuery(db, "SELECT * FROM users WHERE active = ?", 1)
  ```

notes:
  Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria (basePath se ignora en este caso). Hace ping al abrir para detectar errores temprano.

  El parametro `basePath` resuelve el problema de paths relativos en configs YAML: cuando el binario corre desde un directorio distinto al del archivo de config, el path relativo se interpreta incorrectamente. Pasar `filepath.Dir(configPath)` como basePath corrige esto.

  Para mantener el comportamiento anterior (resolver vs cwd), pasar `basePath = ""`.

code:
  package infra

  import (
  	"database/sql"
  	"fmt"
  	"path/filepath"

  	_ "github.com/mattn/go-sqlite3"
  )

  // SQLiteOpen opens (or creates) a SQLite database file with WAL mode and
  // foreign key support enabled. Returns a ready-to-use *sql.DB or an error.
  // Pass ":memory:" for an in-memory database.
  //
  // If basePath is non-empty and path is relative, the path is resolved as
  // filepath.Join(basePath, path). This is useful when the path comes from a
  // config file and must be interpreted relative to that file's directory rather
  // than the process working directory.
  func SQLiteOpen(path string, basePath string) (*sql.DB, error) {
  	if path == "" {
  		return nil, fmt.Errorf("sqlite_open: path must not be empty (use ':memory:' for in-memory)")
  	}
  	resolved := path
  	if basePath != "" && path != ":memory:" && !filepath.IsAbs(path) {
  		resolved = filepath.Join(basePath, path)
  	}
  	dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", resolved)
  	db, err := sql.Open("sqlite3", dsn)
  	if err != nil {
  		return nil, fmt.Errorf("sqlite_open: open %q: %w", resolved, err)
  	}
  	if err := db.Ping(); err != nil {
  		db.Close()
  		return nil, fmt.Errorf("sqlite_open: ping %q: %w", resolved, err)
  	}
  	return db, nil
  }

params_schema:
  {"params":[{"name":"path","desc":"ruta del archivo SQLite (:memory: para base en memoria)"},{"name":"basePath","desc":"ruta base para resolver paths relativos (vacio = relativo al cwd)"}],"output":"conexion sql.DB abierta a SQLite con WAL mode y foreign keys"}


id: sse_handler_go_infra
name: sse_handler
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSEHandler(events <-chan SSEEvent) http.HandlerFunc
tags: ["sse","server-sent-events","http","handler","server","infra","realtime"]
uses_functions: ["sse_send_go_infra"]
uses_types: ["SSEEvent_go_infra"]
returns: []
error_type: error_go_core
imports: ["net/http"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_handler.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 182c3e9aee05fb340eb59fb87da23df14d192d0156d23b362d24c692c3039256
description:
  Retorna un http.HandlerFunc que setea los headers SSE (Content-Type, Cache-Control, Connection, X-Accel-Buffering), consume eventos del canal y los envia con flush a cada cliente conectado. Cierra limpiamente si el cliente se desconecta (context cancelado) o si el canal de eventos se cierra.

example:
  ```go
  events := make(chan SSEEvent, 100)

  routes := []Route{
      {Method: "GET", Path: "/events", Handler: SSEHandler(events)},
  }

  go func() {
      for i := 0; ; i++ {
          events <- SSEEvent{
              Event: "tick",
              ID:    fmt.Sprintf("%d", i),
              Data:  fmt.Sprintf(`{"n":%d}`, i),
          }
          time.Sleep(time.Second)
      }
  }()

  mux := HTTPRouter(routes)
  HTTPServe(":8080", mux, ctx)
  ```

tests:
  ["setea headers SSE correctos","envia eventos del canal al writer","termina si el contexto del request se cancela","termina si el canal de eventos se cierra"]

notes:
  Importante: este handler asume **un canal compartido entre todos los clientes** o un canal por request. Si se quiere broadcast a multiples clientes desde una sola fuente, montar un fan-out por encima (ej: un hub similar a `WSHub` pero para SSE). Para uso tipico (1 cliente = 1 stream) basta con crear el canal dentro del handler externo y pasarlo.

  `X-Accel-Buffering: no` deshabilita el buffering en nginx (sin esto los eventos se acumulan hasta que nginx flushea su buffer, anulando el real-time). En Caddy/Traefik no es necesario pero no estorba.

  El handler **no monta keepalives** automaticamente. Si la conexion va a estar idle mas de ~30s, lanzar `SSEKeepalive` en una goroutine paralela compartiendo el mismo writer.

code:
  package infra

  import "net/http"

  // SSEHandler retorna un http.HandlerFunc que setea los headers SSE,
  // consume eventos del canal y los envia con flush. Cierra limpiamente
  // si el cliente se desconecta (context cancelado) o si el canal se cierra.
  //
  // Headers seteados:
  //   Content-Type: text/event-stream
  //   Cache-Control: no-cache
  //   Connection: keep-alive
  //   X-Accel-Buffering: no   (deshabilita buffering en nginx)
  func SSEHandler(events <-chan SSEEvent) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		flusher, ok := w.(http.Flusher)
  		if !ok {
  			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
  			return
  		}

  		w.Header().Set("Content-Type", "text/event-stream")
  		w.Header().Set("Cache-Control", "no-cache")
  		w.Header().Set("Connection", "keep-alive")
  		w.Header().Set("X-Accel-Buffering", "no")
  		w.WriteHeader(http.StatusOK)
  		flusher.Flush()

  		ctx := r.Context()
  		for {
  			select {
  			case <-ctx.Done():
  				return
  			case ev, ok := <-events:
  				if !ok {
  					return
  				}
  				if err := SSESend(w, ev); err != nil {
  					return
  				}
  			}
  		}
  	}
  }

params_schema:
  {"params":[{"name":"events","desc":"canal de SSEEvent del que se consumen eventos para enviar al cliente. Cerrar el canal termina el handler limpiamente."}],"output":"http.HandlerFunc lista para montarse en una ruta. Sirve un solo cliente por invocacion (cada request abre su propio stream)."}


id: sse_keepalive_go_infra
name: sse_keepalive
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSEKeepalive(w http.ResponseWriter, interval time.Duration, done <-chan struct{})
tags: ["sse","keepalive","server-sent-events","http","server","infra","realtime"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["net/http","time"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_keepalive.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: c1565faa4a1dc62c808a590eb55add257cad6f9e7e7943e8d0d97d4988e22d17
description:
  Envia comentarios SSE periodicos (`: keepalive\n\n`) al writer hasta que done se cierre. Sirve para evitar que proxies o load balancers cierren conexiones inactivas. Bloqueante: lanzar como goroutine. Si w implementa http.Flusher hace flush tras cada keepalive.

example:
  ```go
  http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
      w.Header().Set("Content-Type", "text/event-stream")
      w.Header().Set("Cache-Control", "no-cache")
      w.Header().Set("Connection", "keep-alive")

      done := make(chan struct{})
      defer close(done)

      go SSEKeepalive(w, 15*time.Second, done)

      // Bucle principal de envio de eventos...
  })
  ```

tests:
  ["escribe comentario keepalive periodicamente","termina cuando done se cierra","termina cuando la escritura falla"]

notes:
  Comentario SSE: la spec dice que cualquier linea que empiece con `:` es un comentario y el cliente la ignora. Mantienen viva la conexion sin generar eventos espurios. Recomendado intervalo entre 15-30s para infraestructuras tipicas (nginx default = 60s timeout idle).

  Si la escritura falla (conexion cerrada) la funcion retorna sin error, asumiendo que el handler principal ya detectara la desconexion via `r.Context().Done()`.

code:
  package infra

  import (
  	"net/http"
  	"time"
  )

  // SSEKeepalive envia comentarios SSE periodicos (": keepalive\n\n") al writer
  // hasta que done se cierre. Bloqueante: lanzar como goroutine.
  // Sirve para evitar que proxies/load balancers cierren conexiones inactivas.
  // Si w implementa http.Flusher hace flush tras cada keepalive.
  func SSEKeepalive(w http.ResponseWriter, interval time.Duration, done <-chan struct{}) {
  	if interval <= 0 {
  		interval = 30 * time.Second
  	}
  	ticker := time.NewTicker(interval)
  	defer ticker.Stop()

  	flusher, _ := w.(http.Flusher)

  	for {
  		select {
  		case <-done:
  			return
  		case <-ticker.C:
  			if _, err := w.Write([]byte(": keepalive\n\n")); err != nil {
  				return
  			}
  			if flusher != nil {
  				flusher.Flush()
  			}
  		}
  	}
  }

params_schema:
  {"params":[{"name":"w","desc":"http.ResponseWriter destino del stream SSE. Si implementa http.Flusher se hace flush tras cada keepalive."},{"name":"interval","desc":"intervalo entre keepalives (recomendado 15-30s). Si \u003c= 0 se usa 30s por defecto."},{"name":"done","desc":"canal de cierre. Cuando se cierre, la goroutine retorna inmediatamente."}],"output":"ningun retorno; la funcion retorna cuando done se cierra o cuando una escritura falla (cliente desconectado)"}


id: sse_send_go_infra
name: sse_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSESend(w http.ResponseWriter, event SSEEvent) error
tags: ["sse","server-sent-events","http","server","infra","realtime"]
uses_functions: []
uses_types: ["SSEEvent_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","net/http","strings"]
test_file_path: functions/infra/sse_test.go
file_path: functions/infra/sse_send.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 01fd61430dd7797f13a9ca0b330d7cdc8d200ae5bea0097a2c4c690050ea0d24
description:
  Escribe un evento Server-Sent Events formateado al ResponseWriter segun la spec W3C y hace flush si el writer implementa http.Flusher. Campos opcionales (event, id, retry) solo se incluyen si tienen valor. Data con saltos de linea se traduce a multiples lineas data: segun la spec.

example:
  ```go
  http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
      w.Header().Set("Content-Type", "text/event-stream")
      w.Header().Set("Cache-Control", "no-cache")
      w.Header().Set("Connection", "keep-alive")

      SSESend(w, SSEEvent{
          Event: "metric",
          ID:    "1",
          Data:  `{"cpu": 45.2}`,
      })
  })
  ```

tests:
  ["serializa data simple sin event ni id","incluye event cuando esta presente","incluye id cuando esta presente","incluye retry cuando es positivo","data multilinea genera multiples lineas data:","hace flush si el writer es Flusher"]

notes:
  Funcion atomica: solo formatea y flushea un evento. La gestion del ciclo de vida (headers, loop de eventos, deteccion de desconexion) se hace en `sse_handler`.

  Sigue estrictamente la spec W3C de Server-Sent Events. El doble salto de linea final separa eventos. Para enviar un comentario keepalive (no un evento) escribir `: keepalive\n\n` directamente — no usar esta funcion.

code:
  package infra

  import (
  	"fmt"
  	"net/http"
  	"strings"
  )

  // SSESend escribe un evento SSE formateado al ResponseWriter y hace flush si es posible.
  // Sigue la spec W3C: campos opcionales (event, id, retry) solo se incluyen si tienen valor.
  // Data con saltos de linea se traduce a multiples lineas "data:" segun la spec.
  // Retorna error si la escritura falla.
  func SSESend(w http.ResponseWriter, event SSEEvent) error {
  	var b strings.Builder
  	if event.Event != "" {
  		b.WriteString("event: ")
  		b.WriteString(event.Event)
  		b.WriteByte('\n')
  	}
  	if event.ID != "" {
  		b.WriteString("id: ")
  		b.WriteString(event.ID)
  		b.WriteByte('\n')
  	}
  	if event.Retry > 0 {
  		fmt.Fprintf(&b, "retry: %d\n", event.Retry)
  	}
  	// Data: cada salto de linea genera una nueva linea "data:"
  	for _, line := range strings.Split(event.Data, "\n") {
  		b.WriteString("data: ")
  		b.WriteString(line)
  		b.WriteByte('\n')
  	}
  	b.WriteByte('\n') // separador de eventos

  	if _, err := w.Write([]byte(b.String())); err != nil {
  		return fmt.Errorf("sse send: %w", err)
  	}
  	if flusher, ok := w.(http.Flusher); ok {
  		flusher.Flush()
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"w","desc":"http.ResponseWriter destino. Debe tener headers SSE ya seteados (Content-Type: text/event-stream). Si implementa http.Flusher se hace flush tras la escritura."},{"name":"event","desc":"SSEEvent a serializar. Campos opcionales se omiten si estan vacios. Data multilinea se trocea en varias lineas data: segun la spec."}],"output":"error si la escritura al ResponseWriter falla, nil si el evento se envio y se hizo flush correctamente"}


id: ssh_check_go_infra
name: ssh_check
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHCheck(conn SSHConn) error
description: Verifica conectividad SSH ejecutando un comando noop en el host remoto. Timeout de 5 segundos.
tags: ["ssh","connection","check","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["conecta a organic-machine","falla con host inexistente"]
test_file_path: functions/infra/ssh_check_test.go
file_path: functions/infra/ssh_check.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7e034f12abb02abd36bd9afc59f361c0c966d8774ac72b12654b7a3c3227b6ef
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  if err := SSHCheck(conn); err != nil {
      log.Fatal("no se puede conectar:", err)
  }
  ```

notes:
  Usa BatchMode=yes para fallar inmediatamente si requiere input interactivo (password prompt). ConnectTimeout=5 para no bloquear si el host no responde. StrictHostKeyChecking=accept-new acepta hosts nuevos automaticamente.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHCheck verifica la conectividad SSH ejecutando un comando noop en el host remoto.
  // Retorna nil si la conexion fue exitosa, o error con el detalle del fallo.
  func SSHCheck(conn SSHConn) error {
  	args := conn.sshArgs()
  	args = append(args, "-o", "ConnectTimeout=5")
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, conn.destination(), "exit", "0")

  	out, err := exec.Command("ssh", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("ssh check %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"}],"output":"nil si la conectividad SSH es exitosa, error en caso contrario"}


id: ssh_config_add_entry_go_infra
name: ssh_config_add_entry
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error)
description: Añade un nuevo SSHConfigEntry a la lista. Error si el alias ya existe.
tags: ["ssh","config","add","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt"]
tests: ["añade entry a lista vacia","añade entry a lista existente","error si alias duplicado"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_add_entry.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
notes: No muta el slice original — crea una copia nueva. La validacion de unicidad es por alias exacto.
content_hash: 408d6a6a828cfd716cd7ed46fffd195f54c37a8f75690affd6f72d7cb6879369
example:
  ```go
  newEntry := SSHConfigEntry{Alias: "staging", HostName: "10.0.0.2", User: "deploy"}
  updated, err := SSHConfigAddEntry(entries, newEntry)
  ```

code:
  package infra

  import "fmt"

  // SSHConfigAddEntry añade un nuevo entry a la lista. Retorna error si el alias
  // ya existe. No muta el slice original.
  func SSHConfigAddEntry(entries []SSHConfigEntry, entry SSHConfigEntry) ([]SSHConfigEntry, error) {
  	for _, e := range entries {
  		if e.Alias == entry.Alias {
  			return nil, fmt.Errorf("alias %q already exists", entry.Alias)
  		}
  	}
  	result := make([]SSHConfigEntry, len(entries), len(entries)+1)
  	copy(result, entries)
  	return append(result, entry), nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista actual de SSHConfigEntry"},{"name":"entry","desc":"nuevo entry a añadir con alias unico"}],"output":"nueva lista con el entry añadido al final"}


id: ssh_config_find_go_infra
name: ssh_config_find
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool)
description: Busca un entry por alias en la lista de SSHConfigEntry.
tags: ["ssh","config","search","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: []
tests: ["encuentra entry existente","retorna false para alias inexistente"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_find.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 15d325b1845644bdb2f4db5a28866db5fa6ee0f787ddbe562b6bfb92d8cb0737
example:
  ```go
  entry, ok := SSHConfigFind(entries, "myserver")
  if ok {
      conn := entry.ToSSHConn()
      SSHCheck(conn)
  }
  ```

notes:
  Busqueda lineal por coincidencia exacta del alias. Para pocos entries (tipico en un SSH config personal) es suficiente.

code:
  package infra

  // SSHConfigFind busca un entry por alias en la lista. Retorna el entry y true
  // si lo encuentra, o un SSHConfigEntry vacio y false si no existe.
  func SSHConfigFind(entries []SSHConfigEntry, alias string) (SSHConfigEntry, bool) {
  	for _, e := range entries {
  		if e.Alias == alias {
  			return e, true
  		}
  	}
  	return SSHConfigEntry{}, false
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry donde buscar"},{"name":"alias","desc":"nombre del host a buscar (case-sensitive)"}],"output":"el SSHConfigEntry encontrado y true, o entry vacio y false si no existe"}


id: ssh_config_parse_go_infra
name: ssh_config_parse
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigParse(content string) []SSHConfigEntry
description: Parsea el contenido de un archivo ~/.ssh/config y retorna una lista de SSHConfigEntry.
tags: ["ssh","config","parser","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["bufio","strconv","strings"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_parse.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 71a1e099fe017854a19d9de99d8948b74c4bf9624fa336dc8c83466c436992ad
example:
  ```go
  content := `Host myserver
      HostName 192.168.1.100
      User deploy
      Port 2222
      IdentityFile ~/.ssh/id_ed25519`

  entries := SSHConfigParse(content)
  // entries[0].Alias == "myserver"
  // entries[0].HostName == "192.168.1.100"
  ```

tests:
  ["parsea config con multiples hosts","ignora comentarios y lineas vacias","ignora hosts con wildcards","parsea opciones extra en Options map"]

notes:
  Ignora bloques `Host *` y `Host 192.168.?.*` (wildcards). Soporta directivas con separador espacio o `=`. Las directivas no reconocidas se almacenan en el map `Options`.

code:
  package infra

  import (
  	"bufio"
  	"strconv"
  	"strings"
  )

  // SSHConfigParse parsea el contenido de un archivo ~/.ssh/config y retorna
  // una lista de SSHConfigEntry. Ignora comentarios y lineas vacias.
  // Los bloques Host con wildcards (* o ?) se ignoran.
  func SSHConfigParse(content string) []SSHConfigEntry {
  	var entries []SSHConfigEntry
  	var current *SSHConfigEntry

  	scanner := bufio.NewScanner(strings.NewReader(content))
  	for scanner.Scan() {
  		line := strings.TrimSpace(scanner.Text())
  		if line == "" || strings.HasPrefix(line, "#") {
  			continue
  		}

  		key, value := splitDirective(line)
  		if key == "" {
  			continue
  		}

  		if strings.EqualFold(key, "Host") {
  			if current != nil && current.Alias != "" {
  				entries = append(entries, *current)
  			}
  			if strings.ContainsAny(value, "*?") {
  				current = nil
  				continue
  			}
  			current = &SSHConfigEntry{
  				Alias:   value,
  				Options: make(map[string]string),
  			}
  			continue
  		}

  		if current == nil {
  			continue
  		}

  		switch strings.ToLower(key) {
  		case "hostname":
  			current.HostName = value
  		case "user":
  			current.User = value
  		case "port":
  			if p, err := strconv.Atoi(value); err == nil {
  				current.Port = p
  			}
  		case "identityfile":
  			current.IdentityFile = value
  		default:
  			current.Options[key] = value
  		}
  	}

  	if current != nil && current.Alias != "" {
  		entries = append(entries, *current)
  	}
  	return entries
  }

  // splitDirective separa una linea "Key Value" o "Key=Value" en clave y valor.
  func splitDirective(line string) (string, string) {
  	// Intentar separar por =
  	if idx := strings.IndexByte(line, '='); idx >= 0 {
  		return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
  	}
  	// Separar por primer espacio/tab
  	fields := strings.SplitN(line, " ", 2)
  	if len(fields) < 2 {
  		fields = strings.SplitN(line, "\t", 2)
  	}
  	if len(fields) < 2 {
  		return "", ""
  	}
  	return strings.TrimSpace(fields[0]), strings.TrimSpace(fields[1])
  }

params_schema:
  {"params":[{"name":"content","desc":"texto completo del archivo ~/.ssh/config"}],"output":"lista de SSHConfigEntry, uno por cada bloque Host (sin wildcards)"}


id: ssh_config_read_go_infra
name: ssh_config_read
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHConfigRead() ([]SSHConfigEntry, error)
description: Lee y parsea ~/.ssh/config. Retorna lista vacia si el archivo no existe.
tags: ["ssh","config","read","remote"]
uses_functions: ["ssh_config_parse_go_infra"]
uses_types: ["ssh_config_entry_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
tests: []
file_path: functions/infra/ssh_config_read.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 9901646f0309183d802c801b89db4e71356d8e4f76d3c3946486436ba1926fdd
params_schema: {"output":"lista de SSHConfigEntry parseados del archivo ~/.ssh/config"}
example:
  ```go
  entries, err := SSHConfigRead()
  if err != nil {
      log.Fatal(err)
  }
  for _, e := range entries {
      fmt.Printf("%s -> %s@%s\n", e.Alias, e.User, e.HostName)
  }
  ```

notes:
  Si `~/.ssh/config` no existe, retorna lista vacia y nil (no es error — el usuario simplemente no tiene config aun). Usa `SSHConfigParse` internamente.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // SSHConfigRead lee y parsea el archivo ~/.ssh/config. Si el archivo no existe,
  // retorna una lista vacia sin error (config todavia no creado).
  func SSHConfigRead() ([]SSHConfigEntry, error) {
  	path := filepath.Join(os.Getenv("HOME"), ".ssh", "config")
  	data, err := os.ReadFile(path)
  	if err != nil {
  		if os.IsNotExist(err) {
  			return nil, nil
  		}
  		return nil, fmt.Errorf("ssh config read: %w", err)
  	}
  	return SSHConfigParse(string(data)), nil
  }


id: ssh_config_remove_entry_go_infra
name: ssh_config_remove_entry
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error)
description: Elimina un entry por alias de la lista. Error si el alias no existe.
tags: ["ssh","config","remove","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt"]
example:
  ```go
  updated, err := SSHConfigRemoveEntry(entries, "old-server")
  ```

tests: ["elimina entry existente","error si alias no existe"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_remove_entry.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
notes: No muta el slice original — crea una copia nueva sin el entry eliminado.
content_hash: 76bbba25eefd7f9b17fbd3c33b04f05e7c2438e7349a055ef7dbcf7757a48653
code:
  package infra

  import "fmt"

  // SSHConfigRemoveEntry elimina el entry con el alias dado. Retorna error si
  // el alias no existe. No muta el slice original.
  func SSHConfigRemoveEntry(entries []SSHConfigEntry, alias string) ([]SSHConfigEntry, error) {
  	idx := -1
  	for i, e := range entries {
  		if e.Alias == alias {
  			idx = i
  			break
  		}
  	}
  	if idx < 0 {
  		return nil, fmt.Errorf("alias %q not found", alias)
  	}
  	result := make([]SSHConfigEntry, 0, len(entries)-1)
  	result = append(result, entries[:idx]...)
  	result = append(result, entries[idx+1:]...)
  	return result, nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista actual de SSHConfigEntry"},{"name":"alias","desc":"alias del host a eliminar"}],"output":"nueva lista sin el entry eliminado"}


id: ssh_config_render_go_infra
name: ssh_config_render
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SSHConfigRender(entries []SSHConfigEntry) string
description: Convierte una lista de SSHConfigEntry al formato texto de ~/.ssh/config.
tags: ["ssh","config","render","remote"]
uses_functions: []
uses_types: ["ssh_config_entry_go_infra"]
returns: []
imports: ["fmt","sort","strings"]
test_file_path: functions/infra/ssh_config_parse_test.go
file_path: functions/infra/ssh_config_render.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: 5b8dd99fbfbb4063320170ad95a94b38a7aaf7c5f81b532eadf5390b8f427782
example:
  ```go
  entries := []SSHConfigEntry{{
      Alias: "prod", HostName: "10.0.0.1", User: "admin", Port: 22,
  }}
  text := SSHConfigRender(entries)
  // "Host prod\n    HostName 10.0.0.1\n    User admin\n    Port 22\n"
  ```

tests:
  ["renderiza entry completo","renderiza entry minimo solo con alias","separa bloques con linea en blanco","ordena opciones extra alfabeticamente"]

notes:
  Campos vacios o Port=0 se omiten. Las opciones extra se renderizan en orden alfabetico para determinismo.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  	"strings"
  )

  // SSHConfigRender convierte una lista de SSHConfigEntry al formato texto
  // de ~/.ssh/config. Cada bloque se separa con una linea en blanco.
  func SSHConfigRender(entries []SSHConfigEntry) string {
  	var sb strings.Builder
  	for i, e := range entries {
  		if i > 0 {
  			sb.WriteString("\n")
  		}
  		sb.WriteString(fmt.Sprintf("Host %s\n", e.Alias))
  		if e.HostName != "" {
  			sb.WriteString(fmt.Sprintf("    HostName %s\n", e.HostName))
  		}
  		if e.User != "" {
  			sb.WriteString(fmt.Sprintf("    User %s\n", e.User))
  		}
  		if e.Port != 0 {
  			sb.WriteString(fmt.Sprintf("    Port %d\n", e.Port))
  		}
  		if e.IdentityFile != "" {
  			sb.WriteString(fmt.Sprintf("    IdentityFile %s\n", e.IdentityFile))
  		}
  		if len(e.Options) > 0 {
  			keys := make([]string, 0, len(e.Options))
  			for k := range e.Options {
  				keys = append(keys, k)
  			}
  			sort.Strings(keys)
  			for _, k := range keys {
  				sb.WriteString(fmt.Sprintf("    %s %s\n", k, e.Options[k]))
  			}
  		}
  	}
  	return sb.String()
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry a renderizar"}],"output":"texto en formato ~/.ssh/config con bloques Host separados por linea en blanco"}


id: ssh_config_write_go_infra
name: ssh_config_write
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHConfigWrite(entries []SSHConfigEntry) error
description: Escribe entries a ~/.ssh/config con backup automatico del archivo previo.
tags: ["ssh","config","write","remote"]
uses_functions: ["ssh_config_render_go_infra"]
uses_types: ["ssh_config_entry_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
tests: []
file_path: functions/infra/ssh_config_write.go
created_at: 2026-04-08T21:06:03Z
updated_at: 2026-04-08T21:06:03Z
props: []
emits: []
variant: []
content_hash: df8c99a8122ce5c2109d4b549aeb3f98c3803a04ca1f03800a15c57bd534cc23
example:
  ```go
  entries, _ := SSHConfigRead()
  entries, _ = SSHConfigAddEntry(entries, SSHConfigEntry{
      Alias: "prod", HostName: "10.0.0.1", User: "deploy",
  })
  if err := SSHConfigWrite(entries); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Crea `~/.ssh/` con permisos 0700 si no existe. Hace backup a `~/.ssh/config.bak` antes de sobrescribir. El archivo resultante tiene permisos 0600 (solo lectura/escritura para el owner).

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"path/filepath"
  )

  // SSHConfigWrite escribe la lista de entries al archivo ~/.ssh/config.
  // Crea un backup (.config.bak) antes de sobrescribir si el archivo ya existe.
  // Crea el directorio ~/.ssh si no existe.
  func SSHConfigWrite(entries []SSHConfigEntry) error {
  	sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
  	configPath := filepath.Join(sshDir, "config")
  	backupPath := filepath.Join(sshDir, "config.bak")

  	if err := os.MkdirAll(sshDir, 0700); err != nil {
  		return fmt.Errorf("ssh config write: create dir: %w", err)
  	}

  	// Backup si existe
  	if data, err := os.ReadFile(configPath); err == nil {
  		if err := os.WriteFile(backupPath, data, 0600); err != nil {
  			return fmt.Errorf("ssh config write: backup: %w", err)
  		}
  	}

  	content := SSHConfigRender(entries)
  	if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
  		return fmt.Errorf("ssh config write: %w", err)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"entries","desc":"lista de SSHConfigEntry a escribir en ~/.ssh/config"}],"output":"nil si la escritura fue exitosa"}


id: ssh_download_go_infra
name: ssh_download
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHDownload(conn SSHConn, remotePath, localPath string) error
description: Descarga un archivo del host remoto al filesystem local via scp.
tags: ["ssh","scp","download","file","transfer","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["upload y download roundtrip"]
test_file_path: functions/infra/ssh_transfer_test.go
file_path: functions/infra/ssh_download.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0f1671ca287737d3d4c12e1f8cb0da0996f9fd57535af64e3ee18097fdf3dce9
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  err := SSHDownload(conn, "/var/log/app.log", "./app.log")
  ```

notes:
  Descarga un archivo remoto al path local indicado. Para descargar directorios, usar SSHExec con tar/rsync como alternativa.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHDownload descarga un archivo del host remoto al filesystem local via scp.
  func SSHDownload(conn SSHConn, remotePath, localPath string) error {
  	args := conn.scpArgs()
  	src := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
  	args = append(args, src, localPath)

  	out, err := exec.Command("scp", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("scp download from %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"remotePath","desc":"ruta del archivo en el host remoto"},{"name":"localPath","desc":"ruta de destino en el filesystem local"}],"output":"nil si la descarga fue exitosa, error en caso contrario"}


id: ssh_exec_go_infra
name: ssh_exec
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHExec(conn SSHConn, command string) (string, string, int, error)
description: Ejecuta un comando en el host remoto via SSH. Retorna stdout, stderr y exit code separados.
tags: ["ssh","exec","remote","command"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["os/exec"]
tests: ["echo simple","captura stderr","exit code no cero","comando multilinea"]
test_file_path: functions/infra/ssh_exec_test.go
file_path: functions/infra/ssh_exec.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 54a83a717fe225fbd8da6973409db833096cafa910663c21f043c0b4111b2236
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  stdout, stderr, code, err := SSHExec(conn, "df -h /")
  if err != nil {
      log.Fatal(err)
  }
  fmt.Printf("exit=%d\nstdout:\n%s\nstderr:\n%s\n", code, stdout, stderr)
  ```

notes:
  Retorna stdout y stderr como strings separados mas el exit code. Si el comando remoto falla (exit code != 0), NO retorna error — el exit code indica el fallo. Solo retorna error si ssh no pudo conectar o ejecutar. Usa BatchMode=yes para evitar prompts interactivos.

code:
  package infra

  import (
  	"os/exec"
  )

  // SSHExec ejecuta un comando en el host remoto via SSH y retorna stdout, stderr y exit code.
  func SSHExec(conn SSHConn, command string) (string, string, int, error) {
  	args := conn.sshArgs()
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, conn.destination(), command)

  	cmd := exec.Command("ssh", args...)
  	var stdout, stderr []byte
  	cmd.Stdout = writerFunc(func(p []byte) (int, error) {
  		stdout = append(stdout, p...)
  		return len(p), nil
  	})
  	cmd.Stderr = writerFunc(func(p []byte) (int, error) {
  		stderr = append(stderr, p...)
  		return len(p), nil
  	})

  	err := cmd.Run()
  	exitCode := 0
  	if err != nil {
  		if exitErr, ok := err.(*exec.ExitError); ok {
  			exitCode = exitErr.ExitCode()
  		} else {
  			return "", "", -1, err
  		}
  	}
  	return string(stdout), string(stderr), exitCode, nil
  }

  // writerFunc adapta una funcion a io.Writer.
  type writerFunc func([]byte) (int, error)

  func (f writerFunc) Write(p []byte) (int, error) { return f(p) }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"command","desc":"comando a ejecutar en el host remoto"}],"output":"tupla (stdout, stderr, exit_code, error) del comando ejecutado"}


id: ssh_tunnel_close_go_infra
name: ssh_tunnel_close
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHTunnelClose(pid int) error
description: Cierra un tunel SSH enviando SIGTERM al proceso por PID.
tags: ["ssh","tunnel","close","remote"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","syscall"]
tests: ["abre tunel y lo cierra"]
test_file_path: functions/infra/ssh_tunnel_test.go
file_path: functions/infra/ssh_tunnel_close.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c3a586c7b9c474d95545f696d8e5525ef32e69bf3f26a0579ea9bcbd0dec32f1
example:
  ```go
  // Abrir tunel
  pid, _ := SSHTunnelOpen(conn, 5432, "db-server", 5432)
  // ... usar el tunel ...
  // Cerrar
  err := SSHTunnelClose(pid)
  ```

notes:
  Envia SIGTERM (cierre limpio) al proceso ssh. El PID viene de SSHTunnelOpen. Si el proceso ya termino, retorna error.

code:
  package infra

  import (
  	"fmt"
  	"os"
  	"syscall"
  )

  // SSHTunnelClose cierra un tunel SSH enviando SIGTERM al proceso por PID.
  func SSHTunnelClose(pid int) error {
  	proc, err := os.FindProcess(pid)
  	if err != nil {
  		return fmt.Errorf("ssh tunnel close: process %d not found: %w", pid, err)
  	}

  	err = proc.Signal(syscall.SIGTERM)
  	if err != nil {
  		return fmt.Errorf("ssh tunnel close: cannot signal PID %d: %w", pid, err)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"pid","desc":"ID del proceso ssh del tunel abierto (obtenido de ssh_tunnel_open)"}],"output":"nil si el tunel se cerro exitosamente, error en caso contrario"}


id: ssh_tunnel_open_go_infra
name: ssh_tunnel_open
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error)
tags: ["ssh","tunnel","port-forwarding","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings","time"]
tests: ["abre tunel y lo cierra"]
test_file_path: functions/infra/ssh_tunnel_test.go
file_path: functions/infra/ssh_tunnel_open.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 61704714519f542eaf357bc408e5ff197a4eb5a9cc18d9cf9bf32e22689b117c
description:
  Abre un tunel SSH (local port forwarding) en background. Retorna el PID del proceso para cerrarlo despues.

example:
  ```go
  conn := SSHConn{Host: "bastion.example.com", User: "deploy"}
  // Tunel: localhost:5432 -> db-server:5432 via bastion
  pid, err := SSHTunnelOpen(conn, 5432, "db-server", 5432)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Println("tunnel PID:", pid)
  // Usar localhost:5432 para conectar a la BD remota
  // Cerrar con SSHTunnelClose(pid)
  ```

notes:
  Usa ssh -N -f -L para crear el tunel en background. ExitOnForwardFailure=yes falla inmediatamente si el puerto local esta ocupado. remoteHost vacio se interpreta como "localhost" (el servidor SSH mismo). El PID se obtiene buscando el proceso ssh en la tabla de procesos.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  	"time"
  )

  // SSHTunnelOpen abre un tunel SSH (local port forwarding) en background.
  // Mapea localPort en la maquina local a remoteHost:remotePort a traves del servidor SSH.
  // Retorna el PID del proceso ssh para cerrarlo despues con SSHTunnelClose.
  func SSHTunnelOpen(conn SSHConn, localPort int, remoteHost string, remotePort int) (int, error) {
  	if remoteHost == "" {
  		remoteHost = "localhost"
  	}

  	forward := fmt.Sprintf("%d:%s:%d", localPort, remoteHost, remotePort)
  	args := conn.sshArgs()
  	args = append(args, "-o", "BatchMode=yes")
  	args = append(args, "-o", "ExitOnForwardFailure=yes")
  	args = append(args, "-N", "-f", "-L", forward, conn.destination())

  	cmd := exec.Command("ssh", args...)
  	out, err := cmd.CombinedOutput()
  	if err != nil {
  		return 0, fmt.Errorf("ssh tunnel %s: %s", forward, strings.TrimSpace(string(out)))
  	}

  	// ssh -f se detach, buscar el PID del proceso
  	time.Sleep(200 * time.Millisecond)
  	pidCmd := exec.Command("sh", "-c",
  		fmt.Sprintf("ps aux | grep '[s]sh.*-L %s' | awk '{print $2}' | head -1", forward))
  	pidOut, err := pidCmd.Output()
  	if err != nil || strings.TrimSpace(string(pidOut)) == "" {
  		return 0, fmt.Errorf("ssh tunnel started but could not find PID")
  	}

  	var pid int
  	_, err = fmt.Sscanf(strings.TrimSpace(string(pidOut)), "%d", &pid)
  	if err != nil {
  		return 0, fmt.Errorf("ssh tunnel: invalid PID %q", strings.TrimSpace(string(pidOut)))
  	}

  	return pid, nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"localPort","desc":"puerto local donde escuchar conexiones"},{"name":"remoteHost","desc":"host remoto accesible desde el servidor SSH"},{"name":"remotePort","desc":"puerto en el host remoto"}],"output":"PID del proceso ssh del tunel para cerrarlo despues"}


id: ssh_upload_go_infra
name: ssh_upload
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SSHUpload(conn SSHConn, localPath, remotePath string) error
description: Sube un archivo local al host remoto via scp.
tags: ["ssh","scp","upload","file","transfer","remote"]
uses_functions: []
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","os/exec","strings"]
tests: ["upload y download roundtrip"]
test_file_path: functions/infra/ssh_transfer_test.go
file_path: functions/infra/ssh_upload.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ab011740b2cf010a97a9c0ac15ec185c48ed355c3c3819c1755294bb7ccc0260
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy", KeyPath: "~/.ssh/id_ed25519"}
  err := SSHUpload(conn, "./config.yaml", "/home/deploy/app/config.yaml")
  ```

notes:
  Usa scp con -P para el puerto (distinto a ssh que usa -p). Para subir directorios, usar SSHExec con tar/rsync como alternativa.

code:
  package infra

  import (
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // SSHUpload sube un archivo local al host remoto via scp.
  func SSHUpload(conn SSHConn, localPath, remotePath string) error {
  	args := conn.scpArgs()
  	dest := fmt.Sprintf("%s:%s", conn.destination(), remotePath)
  	args = append(args, localPath, dest)

  	out, err := exec.Command("scp", args...).CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("scp upload to %s: %s", conn.destination(), strings.TrimSpace(string(out)))
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"estructura SSHConn con Host, User, Port, IdentityFile"},{"name":"localPath","desc":"ruta del archivo local a subir"},{"name":"remotePath","desc":"ruta de destino en el host remoto"}],"output":"nil si la subida fue exitosa, error en caso contrario"}


id: stop_app_go_infra
name: stop_app
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func StopApp(containerName string, removeImage bool) error
tags: ["docker","stop","remove","deploy","pipeline","infra","container"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/stop_app.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c9f3114f4f02fd26b57eea8a0f4a52153be78503602cdbeb08a3a9fdcd9bef53
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.

uses_functions:
  ["docker_stop_container_go_infra","docker_remove_container_go_infra","docker_remove_image_go_infra"]

example:
  ```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)
  }
  ```

notes:
  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.

code:
  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
  }

params_schema:
  {"params":[{"name":"containerName","desc":"nombre del contenedor Docker a detener (coincide con imageName de deploy_app)"},{"name":"removeImage","desc":"si true elimina tambien la imagen Docker"}],"output":"nil si la app se detuvo exitosamente, error en caso contrario"}


id: systemd_generate_unit_go_infra
name: systemd_generate_unit
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string
tags: ["systemd","unit","service","generate","deploy"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","sort","strings"]
tests: []
file_path: functions/infra/systemd_generate_unit.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 3a6d3577d85f9f239166371c3e7c720e87911f5a9dfbf3424f3f8aa642221c24
description:
  Genera el texto de un archivo .service de systemd para una app. Incluye restart automático y env vars en orden determinista.

example:
  ```go
  unit := SystemdGenerateUnit(
      "dag_engine",
      "/opt/apps/dag_engine/dag_engine",
      "/opt/apps/dag_engine",
      "deploy",
      map[string]string{"PORT": "8080", "DB_PATH": "/opt/apps/dag_engine/data/ops.db"},
  )
  fmt.Println(unit)
  ```

notes:
  Función pura sin I/O. Las env vars se ordenan alfabéticamente para output determinista. Genera un unit con Restart=on-failure y RestartSec=5.

code:
  package infra

  import (
  	"fmt"
  	"sort"
  	"strings"
  )

  // SystemdGenerateUnit genera el texto de un archivo .service de systemd.
  func SystemdGenerateUnit(name, execStart, workDir, user string, env map[string]string) string {
  	var b strings.Builder

  	b.WriteString("[Unit]\n")
  	b.WriteString(fmt.Sprintf("Description=%s\n", name))
  	b.WriteString("After=network.target\n\n")

  	b.WriteString("[Service]\n")
  	b.WriteString("Type=simple\n")
  	b.WriteString(fmt.Sprintf("ExecStart=%s\n", execStart))
  	if workDir != "" {
  		b.WriteString(fmt.Sprintf("WorkingDirectory=%s\n", workDir))
  	}
  	if user != "" {
  		b.WriteString(fmt.Sprintf("User=%s\n", user))
  	}

  	// Environment vars en orden determinista
  	if len(env) > 0 {
  		keys := make([]string, 0, len(env))
  		for k := range env {
  			keys = append(keys, k)
  		}
  		sort.Strings(keys)
  		for _, k := range keys {
  			b.WriteString(fmt.Sprintf("Environment=%s=%s\n", k, env[k]))
  		}
  	}

  	b.WriteString("Restart=on-failure\n")
  	b.WriteString("RestartSec=5\n\n")

  	b.WriteString("[Install]\n")
  	b.WriteString("WantedBy=multi-user.target\n")

  	return b.String()
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del servicio (aparece en Description)"},{"name":"execStart","desc":"comando completo para arrancar la app (ruta absoluta al binario + args)"},{"name":"workDir","desc":"directorio de trabajo del servicio (vacío para omitir)"},{"name":"user","desc":"usuario del sistema bajo el que corre el servicio (vacío para omitir)"},{"name":"env","desc":"variables de entorno key=value para el servicio"}],"output":"texto completo del archivo .service listo para escribir a /etc/systemd/system/"}


id: systemd_install_go_infra
name: systemd_install
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdInstall(conn SSHConn, unitName, unitContent string) error
tags: ["systemd","install","deploy","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/systemd_install.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 69cb9675e2df296cc03b40a220fe781924fae866e8b22dd4a5ac59d5515f3118
description:
  Sube un unit file al host remoto, hace daemon-reload, enable y restart. Idempotente: reemplaza si el unit ya existe.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  unit := SystemdGenerateUnit("dag_engine", "/opt/apps/dag_engine/dag_engine", "/opt/apps/dag_engine", "deploy", nil)
  err := SystemdInstall(conn, "dag_engine", unit)
  ```

notes:
  Escribe el unit a un archivo temporal en /tmp y lo mueve con sudo a /etc/systemd/system/. Requiere que el usuario SSH tenga permisos sudo sin password para systemctl y mv a /etc/systemd/system/.

code:
  package infra

  import "fmt"

  // SystemdInstall sube un unit file al host remoto, hace daemon-reload, enable y restart.
  // Idempotente: si el unit ya existe, lo reemplaza.
  func SystemdInstall(conn SSHConn, unitName, unitContent string) error {
  	// Escribir a archivo temporal y mover a /etc/systemd/system/
  	tmpPath := fmt.Sprintf("/tmp/%s.service", unitName)
  	destPath := fmt.Sprintf("/etc/systemd/system/%s.service", unitName)

  	writeCmd := fmt.Sprintf("cat > %s << 'UNIT_EOF'\n%sUNIT_EOF", tmpPath, unitContent)
  	_, stderr, code, err := SSHExec(conn, writeCmd)
  	if err != nil {
  		return fmt.Errorf("systemd_install: ssh write: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_install: write unit file: %s", stderr)
  	}

  	// Mover a systemd y aplicar
  	cmds := fmt.Sprintf("sudo mv %s %s && sudo systemctl daemon-reload && sudo systemctl enable %s && sudo systemctl restart %s",
  		tmpPath, destPath, unitName, unitName)

  	_, stderr, code, err = SSHExec(conn, cmds)
  	if err != nil {
  		return fmt.Errorf("systemd_install: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_install: %s", stderr)
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit sin extensión (ej: dag_engine)"},{"name":"unitContent","desc":"contenido completo del archivo .service (generado por SystemdGenerateUnit)"}],"output":"nil si el unit se instaló y arrancó correctamente"}


id: systemd_restart_go_infra
name: systemd_restart
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdRestart(conn SSHConn, unitName string) error
description: Reinicia un servicio systemd en un host remoto via SSH.
tags: ["systemd","restart","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/systemd_restart.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 17487dcbac3f36656123000e7807663b88a9d678dc93fc7091f144738f1b363f
example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  if err := SystemdRestart(conn, "dag_engine"); err != nil {
      log.Fatal(err)
  }
  ```

notes:
  Usa `sudo systemctl restart`. Requiere que el usuario SSH tenga permisos sudo. Si el servicio no existe, systemctl retorna error.

code:
  package infra

  import "fmt"

  // SystemdRestart reinicia un servicio systemd en un host remoto.
  func SystemdRestart(conn SSHConn, unitName string) error {
  	_, stderr, code, err := SSHExec(conn, fmt.Sprintf("sudo systemctl restart %s", unitName))
  	if err != nil {
  		return fmt.Errorf("systemd_restart: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("systemd_restart %s: %s", unitName, stderr)
  	}
  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit systemd a reiniciar (sin .service)"}],"output":"nil si el reinicio fue exitoso"}


id: systemd_status_go_infra
name: systemd_status
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error)
tags: ["systemd","status","monitor","service","remote"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt","strings"]
tests: []
file_path: functions/infra/systemd_status.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: e90aa707b3e837e3f3df22a17a4c6341e82b25abb43edae721dfc49b367952a6
description:
  Consulta el estado de un servicio systemd en un host remoto. Retorna estado activo, sub-estado, PID y logs recientes.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  s, err := SystemdStatus(conn, "dag_engine", 20)
  if err != nil {
      log.Fatal(err)
  }
  fmt.Printf("State: %s/%s  PID: %s\n", s.Active, s.SubState, s.MainPID)
  fmt.Println(s.Logs)
  ```

notes:
  Usa `systemctl show` para obtener propiedades sin formato humano. Los logs se obtienen con journalctl y son opcionales (logLines=0 los omite). Si journalctl falla (ej: permisos), los logs quedan vacíos sin error.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // SystemdServiceStatus resultado de consultar el estado de un servicio systemd.
  type SystemdServiceStatus struct {
  	Unit     string
  	Active   string // "active", "inactive", "failed"
  	SubState string // "running", "dead", "failed"
  	MainPID  string
  	Logs     string
  }

  // SystemdStatus consulta el estado de un servicio systemd en un host remoto.
  func SystemdStatus(conn SSHConn, unitName string, logLines int) (SystemdServiceStatus, error) {
  	status := SystemdServiceStatus{Unit: unitName}

  	// Obtener propiedades del servicio
  	cmd := fmt.Sprintf("systemctl show %s --property=ActiveState,SubState,MainPID --no-pager", unitName)
  	stdout, stderr, code, err := SSHExec(conn, cmd)
  	if err != nil {
  		return status, fmt.Errorf("systemd_status: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return status, fmt.Errorf("systemd_status: systemctl show: %s", stderr)
  	}

  	// Parsear propiedades key=value
  	for _, line := range strings.Split(stdout, "\n") {
  		parts := strings.SplitN(line, "=", 2)
  		if len(parts) != 2 {
  			continue
  		}
  		switch parts[0] {
  		case "ActiveState":
  			status.Active = parts[1]
  		case "SubState":
  			status.SubState = parts[1]
  		case "MainPID":
  			status.MainPID = parts[1]
  		}
  	}

  	// Obtener logs recientes
  	if logLines > 0 {
  		logCmd := fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || true", unitName, logLines)
  		logOut, _, _, logErr := SSHExec(conn, logCmd)
  		if logErr == nil {
  			status.Logs = strings.TrimSpace(logOut)
  		}
  	}

  	return status, nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"unitName","desc":"nombre del unit systemd a consultar (sin .service)"},{"name":"logLines","desc":"número de líneas de journalctl a incluir (0 para no incluir logs)"}],"output":"SystemdServiceStatus con Active, SubState, MainPID y Logs del servicio"}


id: vps_setup_app_go_infra
name: vps_setup_app
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error
tags: ["vps","setup","deploy","remote","infra"]
uses_functions: ["ssh_exec_go_infra"]
uses_types: ["ssh_conn_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
tests: []
file_path: functions/infra/vps_setup_app.go
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: f766f9a4e8404e356cb54dfb31e7874dd4bc15069916b0fd00b59777b3735d1d
description:
  Prepara un host remoto para recibir una app: crea directorios, usuario de servicio y asigna ownership.

example:
  ```go
  conn := SSHConn{Host: "192.168.1.100", User: "deploy"}
  err := VPSSetupApp(conn, "dag_engine", "/opt/apps/dag_engine", "deploy")
  ```

notes:
  Idempotente: mkdir -p no falla si el directorio existe, useradd se salta si el usuario existe. Crea subdirectorios `data/` y `logs/` dentro del remoteDir. Requiere sudo.

code:
  package infra

  import "fmt"

  // VPSSetupApp prepara un host remoto para recibir una app:
  // crea directorios, usuario de servicio si no existe, y directorio de datos.
  func VPSSetupApp(conn SSHConn, appName, remoteDir, serviceUser string) error {
  	// Crear directorio de la app y subdirectorios comunes
  	mkdirCmd := fmt.Sprintf("sudo mkdir -p %s/data %s/logs", remoteDir, remoteDir)
  	_, stderr, code, err := SSHExec(conn, mkdirCmd)
  	if err != nil {
  		return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  	}
  	if code != 0 {
  		return fmt.Errorf("vps_setup_app: mkdir: %s", stderr)
  	}

  	// Crear usuario de servicio si se especificó y no existe
  	if serviceUser != "" {
  		userCmd := fmt.Sprintf("id %s >/dev/null 2>&1 || sudo useradd -r -s /usr/sbin/nologin -d %s %s",
  			serviceUser, remoteDir, serviceUser)
  		_, stderr, code, err = SSHExec(conn, userCmd)
  		if err != nil {
  			return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  		}
  		if code != 0 {
  			return fmt.Errorf("vps_setup_app: create user: %s", stderr)
  		}

  		// Asignar ownership al usuario de servicio
  		chownCmd := fmt.Sprintf("sudo chown -R %s:%s %s", serviceUser, serviceUser, remoteDir)
  		_, stderr, code, err = SSHExec(conn, chownCmd)
  		if err != nil {
  			return fmt.Errorf("vps_setup_app: ssh exec: %w", err)
  		}
  		if code != 0 {
  			return fmt.Errorf("vps_setup_app: chown: %s", stderr)
  		}
  	}

  	return nil
  }

params_schema:
  {"params":[{"name":"conn","desc":"conexión SSH al host remoto"},{"name":"appName","desc":"nombre de la app (para logging)"},{"name":"remoteDir","desc":"ruta absoluta donde vivirá la app en el remoto (ej: /opt/apps/dag_engine)"},{"name":"serviceUser","desc":"usuario del sistema para correr el servicio (vacío para omitir creación de usuario)"}],"output":"nil si el setup fue exitoso"}


id: wails_bind_crud_go_infra
name: wails_bind_crud
kind: function
lang: go
domain: infra
version: 1.0.0
purity: pure
signature: GenerateWailsCRUD(spec WailsCRUDSpec) string
tags: ["wails","crud","generator","bindings","codegen","infra"]
uses_functions: []
uses_types: []
returns: []
imports: ["fmt","strings"]
tests: []
file_path: functions/infra/wails_bind_crud.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 021352757307ee8e1012c7a7e7f3ed164224bb5a41d3d56a01c00ac71d8acdf5
description:
  Genera código Go de bindings CRUD para Wails: struct + métodos List/Get/Create/Update/Delete con stubs not-implemented.

example:
  ```go
  code := GenerateWailsCRUD(WailsCRUDSpec{
      EntityName: "User",
      Fields:     []string{"Name string", "Email string", "Role string"},
      WithList:   true,
      WithGet:    true,
      WithCreate: true,
      WithUpdate: true,
      WithDelete: true,
  })
  // Genera: type User struct + ListUsers + GetUser + CreateUser + UpdateUser + DeleteUser
  ```

notes:
  Función pura — genera código como string. Los métodos son stubs con `return fmt.Errorf("not implemented")`. Un agente puede generar el código, insertarlo en app.go, y luego implementar los TODOs.

code:
  package infra

  import (
  	"fmt"
  	"strings"
  )

  // WailsCRUDSpec define la especificación para generar bindings CRUD.
  type WailsCRUDSpec struct {
  	EntityName string   // Nombre de la entidad (PascalCase, ej: "User")
  	Fields     []string // Campos de la entidad (ej: ["Name string", "Email string"])
  	WithList   bool     // Generar List method
  	WithGet    bool     // Generar Get method
  	WithCreate bool     // Generar Create method
  	WithUpdate bool     // Generar Update method
  	WithDelete bool     // Generar Delete method
  }

  // GenerateWailsCRUD genera código Go de bindings CRUD para una entidad.
  // Retorna el código Go como string — función pura.
  func GenerateWailsCRUD(spec WailsCRUDSpec) string {
  	var sb strings.Builder
  	lower := strings.ToLower(spec.EntityName)
  	plural := lower + "s"

  	// Tipo
  	sb.WriteString(fmt.Sprintf("// %s entity\n", spec.EntityName))
  	sb.WriteString(fmt.Sprintf("type %s struct {\n", spec.EntityName))
  	sb.WriteString("\tID string `json:\"id\"`\n")
  	for _, field := range spec.Fields {
  		parts := strings.SplitN(field, " ", 2)
  		if len(parts) == 2 {
  			jsonTag := strings.ToLower(parts[0])
  			sb.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", parts[0], parts[1], jsonTag))
  		}
  	}
  	sb.WriteString("}\n\n")

  	// List
  	if spec.WithList {
  		sb.WriteString(fmt.Sprintf("// List%s retorna todos los %s.\n", spec.EntityName+"s", plural))
  		sb.WriteString(fmt.Sprintf("func (a *App) List%s() ([]%s, error) {\n", spec.EntityName+"s", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement List%s\n", spec.EntityName+"s"))
  		sb.WriteString(fmt.Sprintf("\treturn nil, fmt.Errorf(\"not implemented\")\n"))
  		sb.WriteString("}\n\n")
  	}

  	// Get
  	if spec.WithGet {
  		sb.WriteString(fmt.Sprintf("// Get%s retorna un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Get%s(id string) (%s, error) {\n", spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Get%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s{}, fmt.Errorf(\"not implemented\")\n", spec.EntityName))
  		sb.WriteString("}\n\n")
  	}

  	// Create
  	if spec.WithCreate {
  		sb.WriteString(fmt.Sprintf("// Create%s crea un nuevo %s.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Create%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Create%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Update
  	if spec.WithUpdate {
  		sb.WriteString(fmt.Sprintf("// Update%s actualiza un %s existente.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Update%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Update%s\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower))
  		sb.WriteString("}\n\n")
  	}

  	// Delete
  	if spec.WithDelete {
  		sb.WriteString(fmt.Sprintf("// Delete%s elimina un %s por ID.\n", spec.EntityName, lower))
  		sb.WriteString(fmt.Sprintf("func (a *App) Delete%s(id string) error {\n", spec.EntityName))
  		sb.WriteString(fmt.Sprintf("\t// TODO: implement Delete%s\n", spec.EntityName))
  		sb.WriteString("\treturn fmt.Errorf(\"not implemented\")\n")
  		sb.WriteString("}\n\n")
  	}

  	return sb.String()
  }

params_schema:
  {"params":[{"name":"spec","desc":"estructura WailsCRUDSpec con EntityName, Fields, WithList/Get/Create/Update/Delete flags"}],"output":"string con codigo Go generado (struct + metodos CRUD stubs)"}


id: wails_build_go_infra
name: wails_build
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsBuild(ctx context.Context, cfg WailsBuildConfig) error
tags: ["wails","build","compile","desktop","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os/exec","fmt","strings","context"]
tests: []
file_path: functions/infra/wails_build.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Requiere `wails` CLI instalado. WailsDev bloquea el proceso (es un servidor de desarrollo).
content_hash: 29867491260074c6a0c1bad048b1278cfd099a503463ac4fa26f4c0f30f3e703
description:
  Compila un proyecto Wails para linux/windows/darwin. Incluye WailsDev para modo desarrollo con hot reload.

example:
  ```go
  // Build para linux
  WailsBuild(ctx, WailsBuildConfig{
      Dir:      "/home/user/my-app",
      Platform: "linux",
  })

  // Desarrollo con hot reload + browser
  WailsDev(ctx, "/home/user/my-app", true)
  ```

code:
  package infra

  import (
  	"context"
  	"fmt"
  	"os/exec"
  	"strings"
  )

  // WailsBuildConfig configura la compilación de un proyecto Wails.
  type WailsBuildConfig struct {
  	Dir      string // Directorio del proyecto Wails
  	Platform string // linux, windows, darwin (default: current)
  	Output   string // Nombre del binario de salida
  	Debug    bool   // Build con debug info
  }

  // WailsBuild compila un proyecto Wails para la plataforma especificada.
  func WailsBuild(ctx context.Context, cfg WailsBuildConfig) error {
  	if cfg.Dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"build"}

  	if cfg.Platform != "" {
  		args = append(args, "-platform", cfg.Platform)
  	}
  	if cfg.Output != "" {
  		args = append(args, "-o", cfg.Output)
  	}
  	if cfg.Debug {
  		args = append(args, "-debug")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = cfg.Dir

  	output, err := cmd.CombinedOutput()
  	if err != nil {
  		return fmt.Errorf("wails build failed: %w\n%s", err, string(output))
  	}

  	fmt.Printf("Built successfully: %s\n", strings.TrimSpace(string(output)))
  	return nil
  }

  // WailsDev lanza un proyecto Wails en modo desarrollo con hot reload.
  func WailsDev(ctx context.Context, dir string, browser bool) error {
  	if dir == "" {
  		return fmt.Errorf("dir is required")
  	}

  	args := []string{"dev"}
  	if browser {
  		args = append(args, "-browser")
  	}

  	cmd := exec.CommandContext(ctx, "wails", args...)
  	cmd.Dir = dir
  	cmd.Stdout = nil // inherit
  	cmd.Stderr = nil

  	return cmd.Run()
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context para cancelacion"},{"name":"cfg","desc":"estructura WailsBuildConfig con Dir, Platform (linux/windows/darwin)"}],"output":"nil si la compilacion fue exitosa, error en caso contrario"}


id: wails_emit_event_go_infra
name: wails_emit_event
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsEmitEvent(ctx context.Context, eventName string, data interface{})
tags: ["wails","event","emit","ipc","realtime","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","encoding/json","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_emit_event.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7f4a1a50609433623d66dbb19bd9ab8060876d0fa5456170cd4f4fad3a6ee74d
description:
  Emite eventos tipados de Go al frontend con timestamp automático. Incluye WailsEmitJSON para serialización explícita.

example:
  ```go
  // Emitir evento tipado
  WailsEmitEvent(ctx, "price:update", PriceData{Symbol: "BTC", Price: 67500.0})

  // El frontend lo recibe con useWailsEvent:
  // useWailsEvent({ eventName: 'price:update', onEvent: (data) => ... })
  ```

notes:
  El evento llega al frontend como WailsEventPayload con `type`, `data` y `timestamp`. Complementa `use_wails_event` del lado TS. Requiere la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"encoding/json"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsEventPayload es un envelope para eventos tipados.
  type WailsEventPayload struct {
  	Type      string      `json:"type"`
  	Data      interface{} `json:"data"`
  	Timestamp time.Time   `json:"timestamp"`
  }

  // WailsEmitEvent emite un evento tipado al frontend.
  func WailsEmitEvent(ctx context.Context, eventName string, data interface{}) {
  	payload := WailsEventPayload{
  		Type:      eventName,
  		Data:      data,
  		Timestamp: time.Now(),
  	}
  	runtime.EventsEmit(ctx, eventName, payload)
  }

  // WailsEmitJSON emite datos como JSON string al frontend.
  func WailsEmitJSON(ctx context.Context, eventName string, data interface{}) error {
  	b, err := json.Marshal(data)
  	if err != nil {
  		return fmt.Errorf("marshaling event data: %w", err)
  	}
  	runtime.EventsEmit(ctx, eventName, string(b))
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"eventName","desc":"nombre del evento a emitir (ej: price:update)"},{"name":"data","desc":"datos a serializar y enviar al frontend"}],"output":"void (sin valor de retorno, efecto es emitir el evento)"}


id: wails_stream_data_go_infra
name: wails_stream_data
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error
tags: ["wails","stream","ipc","realtime","chunks","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["context","fmt","time","github.com/wailsapp/wails/v2/pkg/runtime"]
tests: []
file_path: functions/infra/wails_stream_data.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9e95a95081a919411bb80efa578abead56b149c821158ad68f847377ce859ae3
description:
  Envía datos como stream Go→TS con protocolo {name}/{name}:complete/{name}:error. Incluye WailsStreamFunc para generadores.

example:
  ```go
  // Stream de slice
  WailsStreamData(ctx, WailsStreamConfig{
      StreamName: "logs",
      ChunkDelay: 10 * time.Millisecond,
  }, logLines)

  // Stream con generador
  WailsStreamFunc(ctx, "metrics", func(ctx context.Context, ch chan<- Metric) error {
      for {
          select {
          case <-ctx.Done():
              return nil
          case <-time.After(time.Second):
              ch <- collectMetric()
          }
      }
  })

  // Frontend lo recibe con useWailsStream:
  // useWailsStream({ streamName: 'metrics', autoStart: true })
  ```

notes:
  Protocolo: chunks en `{streamName}`, fin en `{streamName}:complete`, error en `{streamName}:error`. Compatible con `use_wails_stream` del lado TS. WailsStreamFunc usa goroutine + canal para datos asíncronos. Requiere Go 1.18+ (generics) y la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino.

code:
  //go:build ignore
  // NOTE: requires wails dependency in target project — use this file by copying into a Wails project

  package infra

  import (
  	"context"
  	"fmt"
  	"time"

  	"github.com/wailsapp/wails/v2/pkg/runtime"
  )

  // WailsStreamConfig configura un stream de datos hacia el frontend.
  type WailsStreamConfig struct {
  	StreamName string        // Nombre del stream (evento base)
  	ChunkDelay time.Duration // Delay entre chunks (default 0)
  }

  // WailsStreamData envía un slice de datos como stream al frontend.
  // Emite cada elemento como chunk en {streamName}, y {streamName}:complete al terminar.
  func WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error {
  	if cfg.StreamName == "" {
  		return fmt.Errorf("stream name is required")
  	}

  	for _, chunk := range data {
  		select {
  		case <-ctx.Done():
  			runtime.EventsEmit(ctx, cfg.StreamName+":error", map[string]string{
  				"message": "stream cancelled",
  			})
  			return ctx.Err()
  		default:
  			runtime.EventsEmit(ctx, cfg.StreamName, chunk)
  			if cfg.ChunkDelay > 0 {
  				time.Sleep(cfg.ChunkDelay)
  			}
  		}
  	}

  	runtime.EventsEmit(ctx, cfg.StreamName+":complete", nil)
  	return nil
  }

  // WailsStreamFunc ejecuta una función generadora y envía resultados como stream.
  // La función generadora envía datos por el canal, y esta función los retransmite al frontend.
  func WailsStreamFunc[T any](ctx context.Context, streamName string, generator func(ctx context.Context, ch chan<- T) error) error {
  	ch := make(chan T, 100)
  	errCh := make(chan error, 1)

  	go func() {
  		defer close(ch)
  		errCh <- generator(ctx, ch)
  	}()

  	for chunk := range ch {
  		runtime.EventsEmit(ctx, streamName, chunk)
  	}

  	if err := <-errCh; err != nil {
  		runtime.EventsEmit(ctx, streamName+":error", map[string]string{
  			"message": err.Error(),
  		})
  		return err
  	}

  	runtime.EventsEmit(ctx, streamName+":complete", nil)
  	return nil
  }

params_schema:
  {"params":[{"name":"ctx","desc":"context de Wails"},{"name":"cfg","desc":"estructura WailsStreamConfig con StreamName y ChunkDelay"},{"name":"data","desc":"slice de datos genericos a enviar en chunks"}],"output":"nil si el stream se completo exitosamente, error en caso contrario"}


id: write_dockerfile_go_infra
name: write_dockerfile
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WriteDockerfile(dir, content string) (string, error)
tags: ["docker","dockerfile","io","write","deploy","infra"]
uses_functions: ["generate_dockerfile_go_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","os","path/filepath"]
test_file_path: functions/infra/write_dockerfile_test.go
file_path: functions/infra/write_dockerfile.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d2b96af570f088cb1ea5aeaf933ba61a5bfee8ccb2f0c08e7b91a6cc72c4aa0d
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.

example:
  ```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
  ```

tests:
  ["escribe Dockerfile en directorio existente","crea directorio si no existe","retorna path absoluto correcto","error si dir es path invalido"]

notes:
  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.

code:
  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
  }

params_schema:
  {"params":[{"name":"dir","desc":"directorio donde crear el archivo Dockerfile (se crea si no existe)"},{"name":"content","desc":"contenido del Dockerfile como string"}],"output":"ruta absoluta del archivo Dockerfile escrito"}


id: ws_broadcast_go_infra
name: ws_broadcast
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSBroadcast(hub *WSHub, msg []byte) error
tags: ["websocket","broadcast","server","fanout","infra","realtime"]
uses_functions: []
uses_types: ["WSHub_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_broadcast.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: d5ab099284d4a0fd14c8a093d23390de05cbe7774d1608e53473c600255ea577
description:
  Envia bytes al canal Broadcast del hub para que se entreguen a todos los clientes WebSocket conectados. La entrega real la hace el loop Run() del hub. Bloqueante hasta que el canal Broadcast tenga espacio (capacidad 256 por defecto). Retorna error si el hub es nil.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  // Notificar a todos los clientes conectados
  msg, _ := json.Marshal(WSMessage{
      Type:     "step_complete",
      Payload:  []byte(`{"step":"build","status":"ok"}`),
      SenderID: "pipeline_runner",
      Ts:       time.Now().UnixMilli(),
  })
  WSBroadcast(hub, msg)
  ```

tests:
  ["envia mensaje al canal Broadcast del hub","retorna error si hub es nil","el hub entrega el mensaje a todos los clientes registrados"]

notes:
  Bloqueante con backpressure controlado: si el canal `Broadcast` se llena (256 mensajes pendientes), la llamada se bloquea hasta que el hub procese alguno. Esto da feedback natural al productor cuando el sistema esta saturado.

  La entrega a clientes individuales es no bloqueante (clientes lentos se desconectan automaticamente). Si necesitas semantica at-least-once con retry, montar la logica encima.

code:
  package infra

  import "fmt"

  // WSBroadcast envia bytes al canal Broadcast del hub para entregar el mensaje
  // a todos los clientes conectados. La entrega real la hace el loop Run() del hub.
  // Bloqueante hasta que el canal Broadcast tenga espacio (capacidad 256 por defecto).
  func WSBroadcast(hub *WSHub, msg []byte) error {
  	if hub == nil {
  		return fmt.Errorf("ws broadcast: hub is nil")
  	}
  	hub.Broadcast <- msg
  	return nil
  }

params_schema:
  {"params":[{"name":"hub","desc":"*WSHub donde estan registrados los clientes. Si es nil retorna error."},{"name":"msg","desc":"bytes a entregar a todos los clientes. Tipicamente JSON serializado de un WSMessage."}],"output":"error si hub es nil. Nil si el mensaje se encolo en el canal Broadcast (la entrega es asincrona)."}


id: ws_handler_go_infra
name: ws_handler
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSHandler(hub *WSHub, origins []string) http.HandlerFunc
tags: ["websocket","handler","http","server","hub","infra","realtime"]
uses_functions: ["ws_upgrader_go_infra"]
uses_types: ["WSHub_go_infra","WSClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["context","crypto/rand","encoding/hex","net/http","time","nhooyr.io/websocket"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_handler.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: ece22798c8c4cce858cfec555eba21d11d86a8850d0b4107d11a873eadaee914
description:
  Retorna un http.HandlerFunc que upgradea la conexion HTTP a WebSocket via WSUpgrader, crea un WSClient con ID hex aleatorio, lo registra en el hub y lanza readPump y writePump como goroutines. La readPump bloquea el handler para mantener la request viva. Al desconectar (error de I/O o canal cerrado) se desregistra el cliente y se cierra la conexion limpiamente.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  routes := []Route{
      {Method: "GET", Path: "/ws", Handler: WSHandler(hub, []string{"example.com"})},
  }
  mux := HTTPRouter(routes)
  HTTPServe(":8080", mux, ctx)
  ```

tests:
  ["upgradea conexion y registra cliente en hub","broadcast del hub llega al cliente conectado","desregistra cliente al desconectar","multiples clientes reciben el broadcast"]

notes:
  El handler asigna un ID hex aleatorio de 16 caracteres a cada cliente (`crypto/rand`). Si se quiere usar IDs propios (UUID, username autenticado), envolver el handler para sobreescribir `client.ID` antes del Register.

  readPump publica todos los mensajes recibidos al `hub.Broadcast` — modo chat-like por defecto. Para procesar mensajes con un callback (sin reenviar a todos), copiar el codigo y reemplazar el Broadcast por la logica deseada. Esta separacion es deliberada: el hub es un mecanismo de fan-out, no un router de mensajes.

  writePump usa write deadline de 10s — si un Write tarda mas, asume cliente muerto y termina. Esto previene goroutines colgadas si el cliente cierra TCP sin enviar Close frame.

code:
  package infra

  import (
  	"context"
  	"crypto/rand"
  	"encoding/hex"
  	"net/http"
  	"time"

  	"nhooyr.io/websocket"
  )

  // WSHandler retorna un http.HandlerFunc que upgradea la conexion HTTP a WebSocket,
  // crea un WSClient, lo registra en el hub y lanza dos goroutines:
  //   - readPump: lee mensajes del Conn y los publica al hub.Broadcast
  //   - writePump: consume del client.Send y escribe al Conn
  //
  // El cliente se desregistra del hub cuando alguna de las pumps termina (cliente
  // desconectado, error de I/O, o canal cerrado). Asigna un ID hex aleatorio si
  // no se sobreescribe externamente.
  func WSHandler(hub *WSHub, origins []string) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		conn, err := WSUpgrader(w, r, origins)
  		if err != nil {
  			return
  		}

  		client := &WSClient{
  			Hub:  hub,
  			Conn: conn,
  			Send: make(chan []byte, 64),
  			ID:   randomID(),
  		}
  		hub.Register <- client

  		// writePump
  		go wsWritePump(client)
  		// readPump (bloqueante en el handler para mantener viva la request)
  		wsReadPump(client)
  	}
  }

  // wsReadPump lee mensajes del Conn y los publica al hub.Broadcast.
  // Termina si Read retorna error (cliente desconectado o cerrado).
  // Al terminar, desregistra el cliente y cierra la conexion.
  func wsReadPump(client *WSClient) {
  	defer func() {
  		// Unregister no bloqueante: si el hub ya esta cerrado, no esperamos
  		select {
  		case client.Hub.Unregister <- client:
  		case <-client.Hub.done:
  		}
  		_ = client.Conn.Close(websocket.StatusNormalClosure, "")
  	}()

  	ctx := context.Background()
  	for {
  		_, data, err := client.Conn.Read(ctx)
  		if err != nil {
  			return
  		}
  		// Encolar al hub.Broadcast sin bloquear si esta lleno
  		select {
  		case client.Hub.Broadcast <- data:
  		default:
  			// Hub saturado: dropear mensaje del cliente para no bloquear el read
  		}
  	}
  }

  // wsWritePump consume del canal Send del cliente y escribe al Conn.
  // Termina si el canal se cierra (hub desregistro al cliente) o si Write falla.
  func wsWritePump(client *WSClient) {
  	for msg := range client.Send {
  		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  		err := client.Conn.Write(ctx, websocket.MessageText, msg)
  		cancel()
  		if err != nil {
  			return
  		}
  	}
  }

  // randomID genera un identificador hex aleatorio de 16 caracteres (8 bytes).
  // No es criptograficamente perfecto para autenticacion — solo identificacion.
  func randomID() string {
  	b := make([]byte, 8)
  	if _, err := rand.Read(b); err != nil {
  		return "anon"
  	}
  	return hex.EncodeToString(b)
  }

params_schema:
  {"params":[{"name":"hub","desc":"*WSHub donde se registran los clientes que se conecten via este handler. Debe estar corriendo (hub.Run() lanzado en goroutine)."},{"name":"origins","desc":"lista de patrones de origen permitidos para el upgrade. Pasa directamente a WSUpgrader. Para dev: `[\"*\"]`. Para prod: lista explicita."}],"output":"http.HandlerFunc lista para montarse en una ruta GET. Cada conexion entrante crea un cliente nuevo en el hub."}


id: ws_hub_go_infra
name: ws_hub
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func NewWSHub() *WSHub
tags: ["websocket","hub","server","broadcast","infra","realtime"]
uses_functions: []
uses_types: ["WSHub_go_infra","WSClient_go_infra"]
returns: ["WSHub_go_infra"]
error_type: error_go_core
imports: []
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_hub.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 8725e9ed3c9352198fcd8ba3dc82fdbcb4ad6378ae98f8356db5e1562f422855
description:
  Crea un WSHub con canales Broadcast (buffereado, capacidad 256), Register y Unregister. El metodo Run() ejecuta el loop principal: select sobre Register/Unregister/Broadcast. Stop() cierra el loop y limpia todos los clientes. Patron hub clasico de Go: un solo writer al mapa de clientes, sin mutex.

example:
  ```go
  hub := NewWSHub()
  go hub.Run()
  defer hub.Stop()

  // Cualquier goroutine puede broadcastear
  WSBroadcast(hub, []byte("evento"))
  ```

tests:
  ["registra y desregistra clientes","broadcast envia mensaje a todos los clientes","cliente lento es desconectado del hub","Stop cierra todos los clientes"]

notes:
  El loop `Run()` es bloqueante y esta diseñado para correr como goroutine durante toda la vida del proceso. La unica forma de pararlo es llamar a `Stop()`.

  Un cliente lento (cuyo canal `Send` esta lleno) es desconectado automaticamente del hub durante el broadcast — esta es la garantia anti-backpressure: ningun cliente puede bloquear el broadcast a los demas.

  El hub no autentica ni autoriza — es solo un fan-out. La auth se hace en el handler antes de registrar el cliente.

code:
  package infra

  import "sync/atomic"

  // WSHub gestiona el ciclo de vida de conexiones WebSocket.
  // Mantiene un mapa de clientes activos y canales para registro,
  // desregistro y broadcast. Se ejecuta como goroutine via su Run().
  type WSHub struct {
  	Clients    map[*WSClient]bool
  	Broadcast  chan []byte
  	Register   chan *WSClient
  	Unregister chan *WSClient
  	done       chan struct{}
  	count      atomic.Int64
  }

  // ClientCount retorna el numero de clientes registrados de forma thread-safe.
  // Util para metricas, dashboards y tests.
  func (h *WSHub) ClientCount() int {
  	return int(h.count.Load())
  }

  // NewWSHub crea un WSHub vacio con canales inicializados.
  // Ejecutar Run() en una goroutine para activar el loop de eventos.
  func NewWSHub() *WSHub {
  	return &WSHub{
  		Clients:    make(map[*WSClient]bool),
  		Broadcast:  make(chan []byte, 256),
  		Register:   make(chan *WSClient),
  		Unregister: make(chan *WSClient),
  		done:       make(chan struct{}),
  	}
  }

  // Run ejecuta el loop principal del hub. Atiende registros, desregistros
  // y broadcasts en un select. Bloqueante: lanzar como goroutine.
  // Para parar, llamar a Stop().
  func (h *WSHub) Run() {
  	for {
  		select {
  		case <-h.done:
  			// Cerrar todos los Send y vaciar el mapa
  			for client := range h.Clients {
  				delete(h.Clients, client)
  				close(client.Send)
  			}
  			h.count.Store(0)
  			return
  		case client := <-h.Register:
  			h.Clients[client] = true
  			h.count.Store(int64(len(h.Clients)))
  		case client := <-h.Unregister:
  			if _, ok := h.Clients[client]; ok {
  				delete(h.Clients, client)
  				close(client.Send)
  				h.count.Store(int64(len(h.Clients)))
  			}
  		case msg := <-h.Broadcast:
  			for client := range h.Clients {
  				select {
  				case client.Send <- msg:
  				default:
  					// Cliente lento: desconectar
  					delete(h.Clients, client)
  					close(client.Send)
  				}
  			}
  			h.count.Store(int64(len(h.Clients)))
  		}
  	}
  }

  // Stop cierra el loop de Run() y limpia todos los clientes.
  // Idempotente — llamar mas de una vez no panica.
  func (h *WSHub) Stop() {
  	select {
  	case <-h.done:
  		// Ya cerrado
  	default:
  		close(h.done)
  	}
  }

params_schema:
  {"output":"*WSHub listo para usar. Llamar a Run() en una goroutine para activar el loop. Llamar a Stop() para terminar limpiamente."}


id: ws_send_go_infra
name: ws_send
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSSend(client *WSClient, msg []byte) error
tags: ["websocket","send","server","infra","realtime"]
uses_functions: []
uses_types: ["WSClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["fmt"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_send.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 25add33d81cc8ccc6d7a869cae2cca2c308bcf64f093dfab6a3c2235432bd010
description:
  Envia bytes al canal Send de un cliente WebSocket especifico de forma no bloqueante. Si el canal esta lleno o el cliente desconectado, retorna error sin bloquear al emisor. Para broadcast a todos los clientes del hub usar WSBroadcast en su lugar.

example:
  ```go
  // Enviar mensaje a un cliente especifico (unicast)
  target := findClientByID(hub, "user-42")
  err := WSSend(target, []byte(`{"type":"notification","text":"hola"}`))
  if err != nil {
      log.Printf("send failed: %v", err)
  }
  ```

tests:
  ["envia mensaje al canal Send del cliente","retorna error si client es nil","retorna error si el canal esta lleno"]

notes:
  Funcion no bloqueante: si el cliente no consume su canal `Send`, este se llena y la funcion retorna error inmediatamente en vez de bloquear al emisor. Esto previene que un cliente lento bloquee a otros productores.

  El mensaje se encola en `client.Send` — la goroutine writePump del cliente lo escribira al `websocket.Conn`. No hay garantia de orden estricto entre llamadas concurrentes a `WSSend` sobre el mismo cliente.

code:
  package infra

  import "fmt"

  // WSSend envia bytes al canal Send de un cliente especifico de forma no bloqueante.
  // Si el canal esta lleno o el cliente desconectado, retorna error sin bloquear al emisor.
  // Para broadcast a todos los clientes usar WSBroadcast.
  func WSSend(client *WSClient, msg []byte) error {
  	if client == nil {
  		return fmt.Errorf("ws send: client is nil")
  	}
  	select {
  	case client.Send <- msg:
  		return nil
  	default:
  		return fmt.Errorf("ws send: client %s send channel full or closed", client.ID)
  	}
  }

params_schema:
  {"params":[{"name":"client","desc":"*WSClient destinatario. Si es nil retorna error."},{"name":"msg","desc":"bytes a enviar. Tipicamente JSON serializado de un WSMessage. Se entregan tal cual al websocket.Conn."}],"output":"error si el canal Send esta lleno (cliente lento) o cerrado (cliente desconectado). Nil si el mensaje se encolo correctamente."}


id: ws_upgrader_go_infra
name: ws_upgrader
kind: function
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func WSUpgrader(w http.ResponseWriter, r *http.Request, origins []string) (*websocket.Conn, error)
tags: ["websocket","upgrade","http","server","infra","realtime"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["fmt","net/http","nhooyr.io/websocket"]
test_file_path: functions/infra/ws_test.go
file_path: functions/infra/ws_upgrader.go
created_at: 2026-04-18T15:29:52Z
updated_at: 2026-04-18T15:29:52Z
props: []
emits: []
variant: []
content_hash: 2e39a5fb970196806ecd51916f7516c8132cfc0d7d777165de541cfc6a178569
description:
  Hace el upgrade de una conexion HTTP a WebSocket usando nhooyr.io/websocket. Si origins contiene `*` se acepta cualquier origen (InsecureSkipVerify=true), en caso contrario OriginPatterns valida el header Origin del cliente con filepath.Match. Retorna el *websocket.Conn listo para Read/Write o un error si el handshake falla.

example:
  ```go
  http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
      conn, err := WSUpgrader(w, r, []string{"example.com", "app.example.com"})
      if err != nil {
          return // error ya escrito al writer
      }
      defer conn.Close(websocket.StatusNormalClosure, "")

      for {
          _, data, err := conn.Read(r.Context())
          if err != nil {
              return
          }
          conn.Write(r.Context(), websocket.MessageText, data) // echo
      }
  })
  ```

tests:
  ["upgradea conexion valida con origen permitido","rechaza origen no permitido","acepta cualquier origen con `*`"]

notes:
  `nhooyr.io/websocket` exige un patron de origen explicito o `InsecureSkipVerify=true` — no admite `*` como pattern. Esta funcion traduce `["*"]` a InsecureSkipVerify para mantener una API uniforme con CORS.

  Para produccion: nunca usar `["*"]`, listar hosts explicitos. La validacion protege contra cross-origin WebSocket hijacking (un sitio malicioso abriendo WS al servidor desde el browser de la victima).

code:
  package infra

  import (
  	"fmt"
  	"net/http"

  	"nhooyr.io/websocket"
  )

  // WSUpgrader hace el upgrade de una conexion HTTP a WebSocket usando nhooyr.io/websocket.
  // Si origins contiene "*" se acepta cualquier origen (InsecureSkipVerify=true).
  // En caso contrario, OriginPatterns valida el header Origin del cliente con filepath.Match.
  // Retorna el *websocket.Conn listo para Read/Write o un error si el handshake falla.
  func WSUpgrader(w http.ResponseWriter, r *http.Request, origins []string) (*websocket.Conn, error) {
  	opts := &websocket.AcceptOptions{}

  	allowAny := false
  	for _, o := range origins {
  		if o == "*" {
  			allowAny = true
  			break
  		}
  	}

  	if allowAny {
  		opts.InsecureSkipVerify = true
  	} else {
  		opts.OriginPatterns = origins
  	}

  	conn, err := websocket.Accept(w, r, opts)
  	if err != nil {
  		return nil, fmt.Errorf("ws upgrade: %w", err)
  	}
  	return conn, nil
  }

params_schema:
  {"params":[{"name":"w","desc":"http.ResponseWriter del request entrante. Debe soportar hijack (no envuelto en middlewares que rompan Hijacker)."},{"name":"r","desc":"*http.Request del cliente que pide el upgrade. Debe contener los headers Connection: Upgrade y Upgrade: websocket."},{"name":"origins","desc":"lista de patrones de origen permitidos (filepath.Match). Si contiene `*` se aceptan todos los origenes (modo inseguro, solo dev). Para produccion: lista explicita de hosts (ej: [`example.com`, `app.example.com`])."}],"output":"*websocket.Conn listo para Read/Write y error. Si el handshake falla (origen no autorizado, headers invalidos), el writer ya tiene la respuesta de error escrita."}


id: docker_tui_go_infra
name: docker_tui
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — TUI fullscreen para gestionar Docker
tags: ["docker","tui","pipeline","infra","bubbletea","devfactory"]
returns: []
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea","github.com/lucasdataproyects/devfactory"]
example:
  ```bash
  cd apps/docker_tui && go run .
  ```

tests: []
file_path: apps/docker_tui/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-15T10:24:32Z
props: []
emits: []
variant: []
content_hash: a3889dab6ee07870a3a5fd44f5889ec4fc2ed87a8f99088c4ca15d968f95a93c
description:
  Pipeline que compone componentes TUI de DevFactory con comandos Docker para crear una aplicacion de terminal interactiva. Gestiona containers, images, volumes, networks y compose.

uses_functions:
  ["new_filtered_list_go_tui","new_list_go_tui","new_spinner_go_tui","new_base_model_go_tui","default_styles_go_tui","run_fullscreen_go_tui","run_cmd_timeout_go_shell","docker_list_containers_go_infra","docker_start_container_go_infra","docker_stop_container_go_infra","docker_container_logs_go_infra","docker_list_images_go_infra","docker_remove_image_go_infra"]

uses_types:
  ["container_go_docker","image_go_docker","volume_go_docker","network_go_docker","compose_project_go_docker","list_model_go_tui","filtered_list_model_go_tui","spinner_model_go_tui","styles_go_tui","base_model_go_tui","cmd_result_go_shell"]

notes:
  Pipeline principal del proyecto Docker TUI. Compone funciones de los dominios tui, shell e infra
  para crear una aplicacion de terminal con 5 vistas: Containers (start/stop/restart/logs),
  Images (list/remove), Volumes (list/remove), Networks (list/remove), Compose (up/down/logs).

  Arquitectura Elm (Model-Update-View) con navegacion por tabs y state machines por vista.

params_schema:
  {"output":"Ejecutable TUI fullscreen que gestiona Docker containers, images, volumes, networks y compose"}


id: init_metabase_go_infra
name: init_metabase
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — Despliega stack Metabase + Postgres en Docker
tags: ["docker","metabase","postgres","pipeline","infra","analytics","launcher"]
uses_types: ["container_info_go_infra","network_go_docker"]
returns: []
error_type: error_go_core
imports: ["os/exec","encoding/json"]
tests: []
file_path: functions/pipelines/init_metabase/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 11062eda7f4a4c138360635c05232d7271d7624bf0742e2923c642c126376860
params_schema: {"output":"stdout: JSON con IDs de red, contenedores (postgres, metabase) y URL de acceso"}
description:
  Pipeline que inicializa un contenedor Metabase con su base de datos Postgres. Crea red Docker, pull de imágenes, inicia Postgres con volume persistente, espera health check y lanza Metabase conectado.

uses_functions:
  ["docker_create_network_go_infra","docker_pull_image_go_infra","docker_run_container_go_infra","docker_inspect_container_go_infra","retry_with_backoff_go_core"]

example:
  ```bash
  # Básico
  go run functions/pipelines/init_metabase/main.go \
    --project analytics \
    --metabase-port 3000 \
    --pg-user metabase \
    --pg-password metabase

  # Con volume para registry.db (detecta cambios en vivo)
  go run functions/pipelines/init_metabase/main.go \
    --project fn_registry \
    --mb-volumes "/home/lucas/fn_registry/registry.db:/data/registry.db"
  ```

  Salida JSON:
  ```json
  {
    "network_id": "abc123...",
    "postgres_id": "def456...",
    "metabase_id": "ghi789...",
    "network_name": "analytics-net",
    "postgres_name": "analytics-postgres",
    "metabase_name": "analytics-metabase",
    "metabase_url": "http://localhost:3000"
  }
  ```

notes:
  El pipeline orquesta 5 pasos secuenciales:

  1. **Red Docker** — crea `{project}-net` con driver bridge
  2. **Pull** — descarga `postgres:16` y `metabase/metabase:latest`
  3. **Postgres** — inicia con volume persistente (named volume por defecto o bind mount con `--pg-volume`)
  4. **Health check** — retry exponencial (hasta ~34 min) con `pg_isready` dentro del contenedor
  5. **Metabase** — conecta a Postgres via red interna, expone en puerto configurable. Con `--mb-volumes` monta volumes adicionales (ej: registry.db para SQLite)
  6. **Permisos** — ajusta ownership de directorios montados para el usuario `metabase` (UID 2000) dentro del contenedor

  Reutiliza conceptualmente `docker_create_network`, `docker_pull_image`, `docker_run_container`, `docker_inspect_container` y `retry_with_backoff`, reimplementadas inline por ser un ejecutable independiente.

  Para destruir el stack:
  ```bash
  docker stop analytics-metabase analytics-postgres
  docker rm analytics-metabase analytics-postgres
  docker network rm analytics-net
  ```

code:
  package main

  import (
  	"encoding/json"
  	"flag"
  	"fmt"
  	"os"
  	"os/exec"
  	"strings"
  	"time"
  )

  // MetabaseResult contiene los IDs y nombres de los recursos creados.
  type MetabaseResult struct {
  	NetworkID    string `json:"network_id"`
  	PostgresID   string `json:"postgres_id"`
  	MetabaseID   string `json:"metabase_id"`
  	NetworkName  string `json:"network_name"`
  	PostgresName string `json:"postgres_name"`
  	MetabaseName string `json:"metabase_name"`
  	MetabaseURL  string `json:"metabase_url"`
  }

  func main() {
  	project := flag.String("project", "", "Prefijo para nombres de contenedores y red (requerido)")
  	mbPort := flag.String("metabase-port", "3000", "Puerto host para Metabase")
  	pgPort := flag.String("pg-port", "5432", "Puerto host para Postgres")
  	pgUser := flag.String("pg-user", "metabase", "Usuario Postgres")
  	pgPass := flag.String("pg-password", "metabase", "Password Postgres")
  	pgDB := flag.String("pg-database", "metabase", "Base de datos Postgres")
  	pgVolume := flag.String("pg-volume", "", "Path host para persistencia Postgres (default: docker named volume)")
  	mbVolumes := flag.String("mb-volumes", "", "Volumes adicionales para Metabase, separados por coma (ej: /host/path:/container/path,/otro:/dest)")
  	flag.Parse()

  	if *project == "" {
  		fmt.Fprintln(os.Stderr, "error: --project requerido")
  		flag.Usage()
  		os.Exit(1)
  	}

  	var extraVolumes []string
  	if *mbVolumes != "" {
  		extraVolumes = strings.Split(*mbVolumes, ",")
  	}

  	result, err := initMetabase(*project, *mbPort, *pgPort, *pgUser, *pgPass, *pgDB, *pgVolume, extraVolumes)
  	if err != nil {
  		fmt.Fprintf(os.Stderr, "error: %v\n", err)
  		os.Exit(1)
  	}

  	enc := json.NewEncoder(os.Stdout)
  	enc.SetIndent("", "  ")
  	enc.Encode(result)
  }

  func initMetabase(project, mbPort, pgPort, pgUser, pgPass, pgDB, pgVolume string, mbExtraVolumes []string) (*MetabaseResult, error) {
  	networkName := project + "-net"
  	pgName := project + "-postgres"
  	mbName := project + "-metabase"

  	// 1. Crear red
  	fmt.Fprintf(os.Stderr, "[1/5] Creando red %s...\n", networkName)
  	netID, err := dockerCreateNetwork(networkName, "bridge")
  	if err != nil {
  		return nil, fmt.Errorf("creating network: %w", err)
  	}

  	// 2. Pull imágenes
  	for i, img := range []string{"postgres:16", "metabase/metabase:latest"} {
  		fmt.Fprintf(os.Stderr, "[2/5] Pulling %s (%d/2)...\n", img, i+1)
  		if err := dockerPull(img); err != nil {
  			return nil, fmt.Errorf("pulling %s: %w", img, err)
  		}
  	}

  	// 3. Iniciar Postgres
  	fmt.Fprintf(os.Stderr, "[3/5] Iniciando Postgres %s...\n", pgName)
  	vol := pgName + "-data:/var/lib/postgresql/data"
  	if pgVolume != "" {
  		vol = pgVolume + ":/var/lib/postgresql/data"
  	}

  	pgArgs := []string{
  		"run", "-d",
  		"--name", pgName,
  		"--network", networkName,
  		"-p", pgPort + ":5432",
  		"-e", "POSTGRES_USER=" + pgUser,
  		"-e", "POSTGRES_PASSWORD=" + pgPass,
  		"-e", "POSTGRES_DB=" + pgDB,
  		"-v", vol,
  		"postgres:16",
  	}
  	pgID, err := dockerCmd(pgArgs...)
  	if err != nil {
  		return nil, fmt.Errorf("starting postgres: %w", err)
  	}

  	// 4. Health check con retry exponencial
  	fmt.Fprintf(os.Stderr, "[4/5] Esperando a que Postgres esté listo...\n")
  	err = retryWithBackoff(func() error {
  		out, execErr := dockerCmd("exec", pgName, "pg_isready", "-U", pgUser)
  		if execErr != nil {
  			return fmt.Errorf("pg_isready: %s", out)
  		}
  		return nil
  	}, 10, 2*time.Second)
  	if err != nil {
  		return nil, fmt.Errorf("waiting for postgres: %w", err)
  	}
  	fmt.Fprintf(os.Stderr, "       Postgres listo.\n")

  	// 5. Iniciar Metabase
  	fmt.Fprintf(os.Stderr, "[5/5] Iniciando Metabase %s...\n", mbName)
  	mbArgs := []string{
  		"run", "-d",
  		"--name", mbName,
  		"--network", networkName,
  		"-p", mbPort + ":3000",
  		"-e", "MB_DB_TYPE=postgres",
  		"-e", "MB_DB_DBNAME=" + pgDB,
  		"-e", "MB_DB_PORT=5432",
  		"-e", "MB_DB_USER=" + pgUser,
  		"-e", "MB_DB_PASS=" + pgPass,
  		"-e", "MB_DB_HOST=" + pgName,
  	}
  	for _, v := range mbExtraVolumes {
  		mbArgs = append(mbArgs, "-v", strings.TrimSpace(v))
  	}
  	mbArgs = append(mbArgs, "metabase/metabase:latest")
  	mbID, err := dockerCmd(mbArgs...)
  	if err != nil {
  		return nil, fmt.Errorf("starting metabase: %w", err)
  	}

  	// 6. Fix permisos para volumes SQLite — Metabase corre como UID 2000
  	for _, v := range mbExtraVolumes {
  		parts := strings.SplitN(strings.TrimSpace(v), ":", 2)
  		if len(parts) < 2 {
  			continue
  		}
  		destPath := parts[1]
  		// Si es un archivo, fix el directorio padre; si es directorio, fix directo
  		dir := destPath
  		if strings.Contains(destPath, ".") {
  			// Probablemente un archivo, usar dirname
  			idx := strings.LastIndex(destPath, "/")
  			if idx > 0 {
  				dir = destPath[:idx]
  			}
  		}
  		fmt.Fprintf(os.Stderr, "       Fijando permisos de %s para usuario metabase...\n", dir)
  		dockerCmd("exec", "-u", "root", mbName, "chown", "metabase:metabase", dir)
  	}

  	mbURL := fmt.Sprintf("http://localhost:%s", mbPort)
  	fmt.Fprintf(os.Stderr, "\nStack listo. Metabase disponible en %s\n", mbURL)

  	return &MetabaseResult{
  		NetworkID:    netID,
  		PostgresID:   pgID,
  		MetabaseID:   mbID,
  		NetworkName:  networkName,
  		PostgresName: pgName,
  		MetabaseName: mbName,
  		MetabaseURL:  mbURL,
  	}, nil
  }

  // --- Funciones Docker (reimplementan las del registry sin imports internos) ---

  func dockerCreateNetwork(name, driver string) (string, error) {
  	return dockerCmd("network", "create", "--driver", driver, name)
  }

  func dockerPull(image string) error {
  	_, err := dockerCmd("pull", image)
  	return err
  }

  func dockerCmd(args ...string) (string, error) {
  	out, err := exec.Command("docker", args...).CombinedOutput()
  	if err != nil {
  		return "", fmt.Errorf("docker %s: %s", args[0], strings.TrimSpace(string(out)))
  	}
  	return strings.TrimSpace(string(out)), nil
  }

  // retryWithBackoff reimplementa RetryWithBackoff de core para funciones () error.
  func retryWithBackoff(fn func() error, maxRetries int, baseDelay time.Duration) error {
  	var lastErr error
  	for attempt := 0; attempt <= maxRetries; attempt++ {
  		if err := fn(); err == nil {
  			return nil
  		} else {
  			lastErr = err
  		}
  		if attempt < maxRetries {
  			delay := baseDelay * (1 << uint(attempt))
  			time.Sleep(delay)
  		}
  	}
  	return fmt.Errorf("all %d retries exhausted: %w", maxRetries+1, lastErr)
  }


id: pipeline_launcher_go_infra
name: pipeline_launcher
kind: pipeline
lang: go
domain: infra
version: 1.0.0
purity: impure
signature: func main() — TUI fullscreen para lanzar pipelines y registrar ejecuciones
tags: ["tui","pipeline","infra","operations","bubbletea","devfactory"]
uses_functions: ["docker_tui_go_infra"]
uses_types: ["base_model_go_tui","filtered_list_model_go_tui","spinner_model_go_tui","styles_go_tui"]
returns: []
error_type: error_go_core
example:
  ```bash
  FN_REGISTRY_ROOT=/home/lucas/fn_registry go run apps/pipeline_launcher/main.go
  ```

tests: []
file_path: apps/pipeline_launcher/main.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-15T10:24:32Z
props: []
emits: []
variant: []
content_hash: 325a95e991256ea7ac923d7953cfa02525af0d4c1d10e509407522257ef19a11
description:
  TUI interactiva que lista pipelines del registry, permite lanzarlos como subprocesos y registra cada ejecución en operations.db. Dos tabs: Pipelines (filtro + launch) y History (historial).

imports:
  ["github.com/charmbracelet/bubbletea","github.com/lucasdataproyects/devfactory","fn-registry/registry","fn-registry/fn_operations"]

notes:
  TUI con dos tabs:

  - **Pipelines**: lista filtrable de todos los pipelines del registry. Enter lanza el pipeline seleccionado como subproceso (`go run .`), muestra spinner durante la ejecución, y al terminar muestra stdout/stderr y registra la ejecución en operations.db.
  - **History**: historial de ejecuciones pasadas con status, duración y timestamp. Enter muestra detalle completo.

  Teclas: Tab/Shift+Tab cambia vista, Enter ejecuta acción, r refresca, / filtra, j/k scroll, Esc vuelve.

  Requiere `FN_REGISTRY_ROOT` apuntando a la raíz del fn-registry para encontrar registry.db y los file_path de los pipelines.

params_schema:
  {"output":"Ejecutable TUI fullscreen con tabs de Pipelines (lista filtrable + launch) e History (ejecuciones)"}


id: extract_script_description_go_shell
name: extract_script_description
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func ExtractScriptDescription(scriptPath string) string
tags: ["shell","script","description","parse","comment","metadata"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["bufio","os","path/filepath","strings"]
tests: []
file_path: functions/shell/extract_script_description.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 564be15e6570c17ba0ac9c9a9b81e9e1a573761b5eac9fe42e25a017bc685320
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/middleware/reader.go
description:
  Parsea un archivo de script y extrae la primera linea de comentario util como descripcion. Salta shebangs y lineas vacias. Si no encuentra descripcion, usa el nombre del archivo.

example:
  ```go
  // Dado un script con:
  // #!/bin/bash
  // # Analisis completo de DNS para un dominio
  desc := shell.ExtractScriptDescription("/path/to/analisis_dns.sh")
  // desc == "Analisis completo de DNS para un dominio"

  // Sin comentario util
  desc2 := shell.ExtractScriptDescription("/path/to/instalar_go.sh")
  // desc2 == "instalar go" (fallback del nombre)
  ```

notes:
  Busca en las primeras 5 lineas. Remueve prefijos comunes como "Script:", "Descripción:", "Description:" del comentario. Si el archivo no existe o no tiene comentarios utiles, genera una descripcion a partir del nombre del archivo reemplazando underscores por espacios.

code:
  package shell

  import (
  	"bufio"
  	"os"
  	"path/filepath"
  	"strings"
  )

  // ExtractScriptDescription parses a script file and returns the first
  // meaningful comment line as a description. Skips shebangs and empty lines.
  // Falls back to the filename (without extension, underscores replaced by spaces).
  func ExtractScriptDescription(scriptPath string) string {
  	file, err := os.Open(scriptPath)
  	if err != nil {
  		return fallbackDescription(scriptPath)
  	}
  	defer file.Close()

  	scanner := bufio.NewScanner(file)
  	lineCount := 0
  	for scanner.Scan() && lineCount < 5 {
  		line := strings.TrimSpace(scanner.Text())
  		lineCount++

  		if strings.HasPrefix(line, "#!") || line == "" {
  			continue
  		}
  		if strings.HasPrefix(line, "#") {
  			desc := strings.TrimSpace(strings.TrimPrefix(line, "#"))
  			desc = strings.TrimSpace(strings.TrimPrefix(desc, "Script:"))
  			desc = strings.TrimSpace(strings.TrimPrefix(desc, "Script para"))
  			desc = strings.TrimSpace(strings.TrimPrefix(desc, "Descripción:"))
  			desc = strings.TrimSpace(strings.TrimPrefix(desc, "Description:"))
  			if desc != "" {
  				return desc
  			}
  		}
  	}

  	return fallbackDescription(scriptPath)
  }

  func fallbackDescription(scriptPath string) string {
  	name := filepath.Base(scriptPath)
  	name = strings.TrimSuffix(name, filepath.Ext(name))
  	return strings.ReplaceAll(name, "_", " ")
  }

params_schema:
  {"params":[{"name":"scriptPath","desc":"ruta al archivo de script (.sh, .ps1, .bat)"}],"output":"descripcion extraida del primer comentario, o nombre del archivo humanizado"}


id: run_cmd_go_shell
name: run_cmd
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func Run(name string, args ...string) core.Result[CmdResult]
description: Ejecuta un comando del sistema con timeout de 30 segundos y devuelve el resultado.
tags: ["shell","command","process","exec"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := Run("ls", "-la")
  ```

tests: []
file_path: functions/shell/run_cmd.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 03340d58d926e39caea6d195fadcf209093ccb2b0438e71a036afd5293f71b85
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // Run ejecuta un comando del sistema con timeout de 30 segundos y devuelve el resultado.
  func Run(name string, args ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del comando (ej: ls, docker)"},{"name":"args","desc":"argumentos del comando (variadic)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_cmd_timeout_go_shell
name: run_cmd_timeout
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunWithTimeout(name string, timeout time.Duration, args ...string) core.Result[CmdResult]
description: Ejecuta un comando del sistema con timeout configurable.
tags: ["shell","command","process","exec","timeout"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["time"]
example:
  ```go
  result := RunWithTimeout("curl", 5*time.Second, "https://example.com")
  ```

tests: []
file_path: functions/shell/run_cmd_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: cbba369224f509041bb020894e57fe0c1055098baed819d148b2056f5dd49933
code:
  package shell

  import (
  	"fmt"
  	"time"
  )

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunWithTimeout ejecuta un comando del sistema con timeout configurable.
  func RunWithTimeout(name string, timeout time.Duration, args ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del comando"},{"name":"timeout","desc":"duración máxima de ejecución"},{"name":"args","desc":"argumentos del comando (variadic)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_pipe_go_shell
name: run_pipe
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunPipe(commands ...string) core.Result[CmdResult]
description: Encadena multiples comandos con pipe y devuelve el resultado final.
tags: ["shell","command","process","pipe"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := RunPipe("ls -la", "grep go", "wc -l")
  ```

tests: []
file_path: functions/shell/run_pipe.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 1bd58988ccad1134eedae63eb5fe7983e4101f99bea29539cf6c6f624c17304e
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunPipe encadena multiples comandos con pipe y devuelve el resultado final.
  func RunPipe(commands ...string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"commands","desc":"comandos a encadenar con pipe (variadic)"}],"output":"Result[CmdResult] con output combinado y exit code"}


id: run_shell_go_shell
name: run_shell
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunShell(command string) core.Result[CmdResult]
description: Ejecuta un comando shell interpretado por /bin/sh.
tags: ["shell","command","process","exec"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
example:
  ```go
  result := RunShell("ls -la | grep go")
  ```

tests: []
file_path: functions/shell/run_shell.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: c8c189fac95f16cb8188717582523dd2be4b8438ff70d74de5ad2f0c43e7c3e6
code:
  package shell

  import "fmt"

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunShell ejecuta un comando shell interpretado por /bin/sh.
  func RunShell(command string) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar (ej: ls -la | grep go)"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: run_shell_timeout_go_shell
name: run_shell_timeout
kind: function
lang: go
domain: shell
version: 1.0.0
purity: impure
signature: func RunShellTimeout(command string, timeout time.Duration) core.Result[CmdResult]
description: Ejecuta un comando shell con timeout configurable.
tags: ["shell","command","process","exec","timeout"]
uses_functions: []
uses_types: ["cmd_result_go_shell","result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["time"]
example:
  ```go
  result := RunShellTimeout("long-running-cmd", 30*time.Second)
  ```

tests: []
file_path: functions/shell/run_shell_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 70df63fea1333e520a872aea87564bef8fb602062677794be8fdd4e68d34ee81
code:
  package shell

  import (
  	"fmt"
  	"time"
  )

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // RunShellTimeout ejecuta un comando shell con timeout configurable.
  func RunShellTimeout(command string, timeout time.Duration) (CmdResult, error) {
  	// stub — implementation in devfactory/shell
  	return CmdResult{}, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"command","desc":"comando shell a ejecutar"},{"name":"timeout","desc":"duración máxima de ejecución"}],"output":"Result[CmdResult] con stdout, stderr, exit code"}


id: which_go_shell
name: which
kind: function
lang: go
domain: shell
version: 1.0.0
purity: pure
signature: func Which(name string) core.Option[string]
description: Busca la ruta de un ejecutable en el PATH del sistema. Devuelve None si no existe.
tags: ["shell","path","lookup"]
uses_functions: []
uses_types: ["option_go_core"]
returns: ["option_go_core"]
imports: []
example:
  ```go
  path := Which("docker")
  ```

tests: []
file_path: functions/shell/which.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Implementacion en devfactory/shell.
content_hash: 54b406f02ed0cbadbc47f6a5ad64c4fce4399be7bcbf59bdc56eceb5fb162965
code:
  package shell

  // Implementation: github.com/lucasdataproyects/devfactory/shell

  // Which busca la ruta de un ejecutable en el PATH del sistema. Devuelve ("", false) si no existe.
  func Which(name string) (string, bool) {
  	// stub — implementation in devfactory/shell
  	return "", false
  }

params_schema:
  {"params":[{"name":"name","desc":"nombre del ejecutable a buscar"}],"output":"Option[string]: Some(ruta_absoluta) o None si no existe"}


id: apply_gradient_go_tui
name: apply_gradient
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func ApplyGradient(lines []string, colors []lipgloss.Color) string
tags: ["tui","ascii","gradient","color","lipgloss","art","header"]
uses_functions: []
uses_types: []
returns: []
imports: ["strings","github.com/charmbracelet/lipgloss"]
tests: []
file_path: functions/tui/apply_gradient.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 531d55847aefb5cc7ac1cde3c742fa893a63721d1bf3867f423be8d0ebfe864a
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/core/gradient.go
description:
  Aplica un degradado de colores linea por linea a texto ASCII art usando lipgloss. Distribuye los colores de la paleta proporcionalmente entre las lineas.

example:
  ```go
  lines := []string{
      "  ██████╗ ███████╗",
      "  ██╔══██╗██╔════╝",
      "  ██║  ██║█████╗  ",
  }
  // Con paleta por defecto
  fmt.Print(tui.ApplyGradient(lines, nil))

  // Con paleta custom
  palette := []lipgloss.Color{
      lipgloss.Color("#ff0000"),
      lipgloss.Color("#00ff00"),
      lipgloss.Color("#0000ff"),
  }
  fmt.Print(tui.ApplyGradient(lines, palette))
  ```

notes:
  Cada linea recibe un color de la paleta distribuido proporcionalmente: `colorIndex = (i * len(colors)) / totalLines`. Para ASCII art con pocas lineas, usar una paleta corta. Para muchas lineas, una paleta larga da transiciones mas suaves.

  `DefaultGradientColors` es una variable publica con 8 colores (purple->blue->cyan->red) que se puede reasignar si se desea cambiar el default global.

code:
  package tui

  import (
  	"strings"

  	"github.com/charmbracelet/lipgloss"
  )

  // DefaultGradientColors is the default color palette for ASCII art gradients.
  // Purple → blue → cyan → red, applied line by line.
  var DefaultGradientColors = []lipgloss.Color{
  	lipgloss.Color("#9b59b6"),
  	lipgloss.Color("#8e44ad"),
  	lipgloss.Color("#3498db"),
  	lipgloss.Color("#2980b9"),
  	lipgloss.Color("#1abc9c"),
  	lipgloss.Color("#16a085"),
  	lipgloss.Color("#e74c3c"),
  	lipgloss.Color("#c0392b"),
  }

  // ApplyGradient applies a color gradient to ASCII art lines.
  // Each line gets a color from the palette, distributed proportionally.
  // Pass nil for colors to use DefaultGradientColors.
  func ApplyGradient(lines []string, colors []lipgloss.Color) string {
  	if len(lines) == 0 {
  		return ""
  	}
  	if len(colors) == 0 {
  		colors = DefaultGradientColors
  	}

  	var result strings.Builder
  	totalLines := len(lines)

  	for i, line := range lines {
  		colorIndex := (i * len(colors)) / totalLines
  		if colorIndex >= len(colors) {
  			colorIndex = len(colors) - 1
  		}
  		styledLine := lipgloss.NewStyle().Foreground(colors[colorIndex]).Render(line)
  		result.WriteString(styledLine)
  		result.WriteString("\n")
  	}

  	result.WriteString("\n")
  	return result.String()
  }

params_schema:
  {"params":[{"name":"lines","desc":"lineas de ASCII art a colorear"},{"name":"colors","desc":"paleta de colores lipgloss; nil usa DefaultGradientColors (purple-\u003eblue-\u003ecyan-\u003ered)"}],"output":"string con las lineas coloreadas concatenadas con newlines"}


id: clear_screen_go_tui
name: clear_screen
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func ClearScreen() string
description: Devuelve el codigo de escape ANSI para limpiar la pantalla del terminal.
tags: ["tui","ansi","terminal","screen"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: functions/tui/clear_screen.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 7bec2db2888a90720d6fd92350ab025292de2588b6c76d269336d3a729fd40b4
params_schema: {"output":"string: código ANSI para limpiar pantalla"}
example:
  ```go
  fmt.Print(ClearScreen())
  // Limpia toda la pantalla y mueve el cursor a la posicion inicial
  ```

notes:
  Combina ESC[2J (borrar pantalla completa) con ESC[H (mover cursor a home). No tiene side effects: solo devuelve el string ANSI.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // ClearScreen devuelve el codigo de escape ANSI para limpiar la pantalla del terminal.
  func ClearScreen() string {
  	return "\033[2J\033[H"
  }


id: confirm_prompt_go_tui
name: confirm_prompt
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func Confirm(prompt string) core.Result[bool]
description: Muestra un dialogo de confirmacion Si/No en terminal y devuelve la eleccion del usuario.
tags: ["tui","confirm","prompt","interactive"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: []
tests: []
file_path: functions/tui/confirm_prompt.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: d495d64f37038962ca2a7654f2d55cee4c42a0fdb2639b22561e191c73182859
example:
  ```go
  result := tui.Confirm("¿Deseas continuar?")
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  if result.Unwrap() {
      fmt.Println("Confirmado")
  }
  ```

notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  import "fmt"

  // Confirm muestra un prompt de confirmacion si/no en la terminal.
  func Confirm(prompt string) (bool, error) {
  	// stub — implementation in devfactory/tui
  	return false, fmt.Errorf("not implemented")
  }

params_schema:
  {"params":[{"name":"prompt","desc":"pregunta a mostrar al usuario"}],"output":"Result[bool]: true si confirmó, false si rechazó"}


id: dark_styles_go_tui
name: dark_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DarkStyles() Styles
tags: ["tui","styles","dark","constructor"]
uses_functions: []
uses_types: []
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  styles := DarkStyles()
  model := NewProgressWithStyles(100.0, "Compilando", styles)
  ```

tests: []
file_path: functions/tui/dark_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Equivalente a NewStyles(DarkTheme()). Usa esta funcion para aplicaciones TUI en terminales oscuras.
content_hash: ca82d3010ff3f40dc80878727ad3d86bf01fa0c8d015ef356e42f6fd13eef623
params_schema: {"output":"Styles: conjunto de estilos lipgloss oscuros"}
description:
  Construye estilos oscuros combinando DarkTheme con NewStyles. Atajo conveniente para terminales con fondo negro.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DarkStyles crea estilos oscuros usando el tema dark.
  func DarkStyles() Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }


id: dark_theme_go_tui
name: dark_theme
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DarkTheme() Theme
tags: ["tui","theme","colors","dark","constructor"]
uses_functions: []
uses_types: []
returns: ["theme_go_tui"]
imports: []
example:
  ```go
  theme := DarkTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/dark_theme.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Alternativa oscura al DefaultTheme. Colores de alto contraste para fondos negros.
content_hash: 41b83b51cb49a2ce8a1527c1bc259642774f56566a667f52f394cddc80c5c0d0
params_schema: {"output":"Theme: paleta de colores oscura"}
description:
  Construye un tema de colores oscuro para componentes TUI. Paleta optimizada para terminales con fondo negro.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DarkTheme crea un tema de colores oscuro.
  func DarkTheme() Theme {
  	// stub — implementation in devfactory/tui
  	return Theme{}
  }


id: default_styles_go_tui
name: default_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DefaultStyles() Styles
tags: ["tui","styles","constructor"]
uses_functions: []
uses_types: []
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  styles := DefaultStyles()
  model := NewSpinnerWithStyle("Cargando...", styles)
  ```

tests: []
file_path: functions/tui/default_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Equivalente a NewStyles(DefaultTheme()). Usa esta funcion cuando no necesitas personalizar el tema.
content_hash: b11ea0f07200b67b574f431bfe016933ad498273724be658dda7adb9ddde3b2b
params_schema: {"output":"Styles: conjunto de estilos lipgloss por defecto"}
description:
  Construye estilos por defecto combinando DefaultTheme con NewStyles. Atajo conveniente para el caso comun.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DefaultStyles crea estilos por defecto usando el tema base.
  func DefaultStyles() Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }


id: default_theme_go_tui
name: default_theme
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DefaultTheme() Theme
tags: ["tui","theme","colors","constructor"]
uses_functions: []
uses_types: []
returns: ["theme_go_tui"]
imports: []
example:
  ```go
  theme := DefaultTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/default_theme.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Tema base del sistema de estilos. Usar DarkTheme para terminales con fondo oscuro.
content_hash: b0b3e6c5e61df5ea9974ae34e5458a027fb4170e77b619497a40ab2230212a6e
params_schema: {"output":"Theme: paleta de colores clara/por defecto"}
description:
  Construye el tema de colores por defecto para componentes TUI. Paleta clara optimizada para terminales con fondo blanco.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // DefaultTheme crea el tema de colores por defecto.
  func DefaultTheme() Theme {
  	// stub — implementation in devfactory/tui
  	return Theme{}
  }


id: draw_box_go_tui
name: draw_box
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DrawBox(content string, width int, style lipgloss.Style) string
tags: ["tui","box","border","unicode","draw","lipgloss"]
uses_functions: []
uses_types: []
returns: []
imports: ["github.com/charmbracelet/lipgloss"]
tests: []
file_path: functions/tui/draw_box.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 696de9711c7c033c32ab18e60ce42a8b6d21342652f55f3452d10542c600c5b2
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/ui/styles.go
description:
  Dibuja un box unicode (bordes dobles) alrededor de contenido con ancho y estilo lipgloss configurables.

example:
  ```go
  style := lipgloss.NewStyle().Foreground(lipgloss.Color("#00d7ff"))
  box := tui.DrawBox("  Hello World  ", 40, style)
  fmt.Println(box)
  // ╔══════════════════════════════════════╗
  //   Hello World
  // ╚══════════════════════════════════════╝
  ```

notes:
  Usa caracteres de box drawing doble (BoxTL=╔, BoxH=═, etc.) definidos en box_chars.go. El contenido no se recorta ni centra automaticamente — el caller controla el formato interior.

code:
  package tui

  import "github.com/charmbracelet/lipgloss"

  // DrawBox draws a Unicode box around content with the given width and style.
  func DrawBox(content string, width int, style lipgloss.Style) string {
  	fill := lipgloss.NewStyle().Render(
  		lipgloss.PlaceHorizontal(width-2, lipgloss.Left, BoxH, lipgloss.WithWhitespaceChars(BoxH)),
  	)
  	topLine := style.Render(BoxTL + fill + BoxTR)
  	bottomLine := style.Render(BoxBL + fill + BoxBR)

  	return topLine + "\n" + content + "\n" + bottomLine
  }

params_schema:
  {"params":[{"name":"content","desc":"texto interior del box"},{"name":"width","desc":"ancho total del box en caracteres"},{"name":"style","desc":"estilo lipgloss para los bordes"}],"output":"string con el box renderizado (top + content + bottom)"}


id: draw_separator_go_tui
name: draw_separator
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func DrawSeparator(width int, style lipgloss.Style, sepChar string) string
tags: ["tui","separator","line","draw","lipgloss","divider"]
uses_functions: []
uses_types: []
returns: []
imports: ["github.com/charmbracelet/lipgloss"]
tests: []
file_path: functions/tui/draw_separator.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 4923bbacd63b5082fb341243c5dd797db0af39ca9aee86f776e74a482eef4ecd
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/ui/styles.go
description:
  Dibuja una linea separadora horizontal con ancho, estilo y caracter configurables. Por defecto usa el caracter de separacion simple (─).

example:
  ```go
  dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#6c6c6c"))
  sep := tui.DrawSeparator(60, dim, "")
  fmt.Println(sep)
  // ────────────────────────────────────────────────────────────

  // Con caracter custom
  sep2 := tui.DrawSeparator(40, dim, "═")
  ```

notes:
  Si `sepChar` es vacio, usa `BoxSep` (─) de box_chars.go. Para lineas dobles usar "═", para puntos "·", etc.

code:
  package tui

  import "github.com/charmbracelet/lipgloss"

  // DrawSeparator draws a horizontal separator line of the given width.
  func DrawSeparator(width int, style lipgloss.Style, sepChar string) string {
  	if sepChar == "" {
  		sepChar = BoxSep
  	}
  	return style.Render(
  		lipgloss.PlaceHorizontal(width, lipgloss.Left, sepChar, lipgloss.WithWhitespaceChars(sepChar)),
  	)
  }

params_schema:
  {"params":[{"name":"width","desc":"ancho de la linea en caracteres"},{"name":"style","desc":"estilo lipgloss para el separador"},{"name":"sepChar","desc":"caracter de separacion; vacio usa BoxSep (─)"}],"output":"string con la linea separadora renderizada"}


id: hide_cursor_go_tui
name: hide_cursor
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func HideCursor() string
description: Devuelve el codigo de escape ANSI para ocultar el cursor del terminal.
tags: ["tui","ansi","terminal","cursor"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```go
  fmt.Print(HideCursor())
  // Oculta el cursor del terminal usando DECTCEM
  ```

tests: []
file_path: functions/tui/hide_cursor.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 417ef4fa35e41278599091250fbe7e8bdec80f667cd229353ef7d0297c0729ae
params_schema: {"output":"string: código ANSI para ocultar el cursor"}
notes:
  Usa la secuencia DECTCEM (ESC[?25l) del estandar VT220. Recordar restaurar el cursor con ShowCursor al finalizar la aplicacion.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // HideCursor devuelve el codigo de escape ANSI para ocultar el cursor del terminal.
  func HideCursor() string {
  	return "\033[?25l"
  }


id: load_ascii_art_go_tui
name: load_ascii_art
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func LoadASCIIArt(staticPath string, colors []lipgloss.Color) string
tags: ["tui","ascii","art","header","gradient","random","file"]
uses_functions: ["apply_gradient_go_tui"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["bufio","math/rand","os","path/filepath","strings","time","github.com/charmbracelet/lipgloss"]
tests: []
file_path: functions/tui/load_ascii_art.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: a5ce254e7fe42daba9fe97fad6ffbfd69786f13aa01dbc44b220d3d249e12d9c
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/middleware/assets.go
description:
  Lee un archivo .txt aleatorio de un directorio y lo renderiza con degradado de colores. Util para headers de TUIs con ASCII art variado.

example:
  ```go
  // Cada app pone sus archivos .txt en un directorio static/
  header := tui.LoadASCIIArt("./static", nil)
  if header != "" {
      fmt.Print(header)
  }

  // Con paleta custom
  palette := []lipgloss.Color{
      lipgloss.Color("#ff6b6b"),
      lipgloss.Color("#feca57"),
      lipgloss.Color("#48dbfb"),
  }
  header = tui.LoadASCIIArt("./static", palette)
  ```

notes:
  Patron de uso: cada app TUI mantiene un directorio `static/` con archivos `.txt` de ASCII art. Al iniciar, `LoadASCIIArt` elige uno al azar y le aplica el degradado de colores. Esto da variedad visual en cada ejecucion.

  Fallback silencioso: si el directorio no existe, no tiene .txt, o hay error de lectura, retorna "" sin error. El caller decide si mostrar un fallback.

code:
  package tui

  import (
  	"bufio"
  	"math/rand"
  	"os"
  	"path/filepath"
  	"strings"
  	"time"

  	"github.com/charmbracelet/lipgloss"
  )

  // LoadASCIIArt reads a random .txt file from staticPath and returns it
  // rendered with a color gradient. Pass nil for colors to use DefaultGradientColors.
  // Returns "" if no .txt files are found or on any error.
  func LoadASCIIArt(staticPath string, colors []lipgloss.Color) string {
  	files, err := os.ReadDir(staticPath)
  	if err != nil {
  		return ""
  	}

  	var txtFiles []string
  	for _, f := range files {
  		if !f.IsDir() && strings.HasSuffix(f.Name(), ".txt") {
  			txtFiles = append(txtFiles, f.Name())
  		}
  	}
  	if len(txtFiles) == 0 {
  		return ""
  	}

  	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
  	selected := txtFiles[rng.Intn(len(txtFiles))]

  	file, err := os.Open(filepath.Join(staticPath, selected))
  	if err != nil {
  		return ""
  	}
  	defer file.Close()

  	var lines []string
  	sc := bufio.NewScanner(file)
  	for sc.Scan() {
  		lines = append(lines, sc.Text())
  	}

  	if len(lines) == 0 {
  		return ""
  	}
  	return ApplyGradient(lines, colors)
  }

params_schema:
  {"params":[{"name":"staticPath","desc":"directorio con archivos .txt de ASCII art"},{"name":"colors","desc":"paleta de colores para el degradado; nil usa DefaultGradientColors"}],"output":"string con ASCII art coloreado, o vacio si no hay archivos o hay error"}


id: new_base_model_go_tui
name: new_base_model
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewBaseModel() BaseModel
tags: ["tui","base","constructor"]
uses_functions: []
uses_types: []
returns: ["base_model_go_tui"]
imports: []
example:
  ```go
  base := NewBaseModel()
  ```

tests: []
file_path: functions/tui/new_base_model.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e2707cafbeaea45e60fd402191df039f97ffb6c17cb9e0bc10e5ab07c630b48d
params_schema: {"output":"BaseModel: modelo base con dimensiones y estilos"}
description:
  Construye un modelo base con dimensiones de terminal y estilos por defecto. Sirve como fundacion para componer modelos mas complejos.

notes:
  Modelo raiz que encapsula estado comun (dimensiones, estilos, keybindings) compartido por todos los componentes TUI.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewBaseModel crea un modelo base con configuracion por defecto.
  func NewBaseModel() BaseModel {
  	// stub — implementation in devfactory/tui
  	return BaseModel{}
  }


id: new_confirm_go_tui
name: new_confirm
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewConfirm(prompt string) ConfirmModel
description: Construye un modelo de dialogo de confirmacion con una pregunta si/no.
tags: ["tui","confirm","dialog","constructor"]
uses_functions: []
uses_types: []
returns: ["confirm_model_go_tui"]
imports: []
example:
  ```go
  model := NewConfirm("Deseas continuar?")
  ```

tests: []
file_path: functions/tui/new_confirm.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: bbdfb649d61ed0f2c6b69722d2ccd01bb1c689f1de930c1900167028e97cdc4e
notes:
  Dialogo binario si/no. El resultado se obtiene del modelo tras la interaccion del usuario en el loop de Bubble Tea.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewConfirm crea un modelo de dialogo de confirmacion si/no.
  func NewConfirm(prompt string) ConfirmModel {
  	// stub — implementation in devfactory/tui
  	return ConfirmModel{}
  }

params_schema:
  {"params":[{"name":"prompt","desc":"pregunta a mostrar en el diálogo"}],"output":"ConfirmModel: modelo de confirmación interactivo"}


id: new_filtered_list_go_tui
name: new_filtered_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewFilteredList(items []ListItem, placeholder string) FilteredListModel
tags: ["tui","list","filter","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["filtered_list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_filtered_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Incluye un input de texto que filtra los items en tiempo real por coincidencia parcial.
content_hash: 06d931fd24b4712da8e2331b0f279d06b5c72a25c822e12c16bf4f812e4fe21f
description:
  Construye un modelo de lista con campo de busqueda integrado. El placeholder se muestra en el input de filtro cuando esta vacio.

example:
  ```go
  items := []ListItem{
      {Title: "main.go", Description: "Punto de entrada"},
      {Title: "server.go", Description: "Servidor HTTP"},
  }
  model := NewFilteredList(items, "Buscar archivo...")
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewFilteredList crea un modelo de lista con filtrado por texto.
  func NewFilteredList(items []ListItem, placeholder string) FilteredListModel {
  	// stub — implementation in devfactory/tui
  	return FilteredListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem con título y descripción"},{"name":"placeholder","desc":"texto a mostrar en el input de filtro vacío"}],"output":"FilteredListModel: lista con búsqueda en tiempo real"}


id: new_list_go_tui
name: new_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewList(items []ListItem) ListModel
tags: ["tui","list","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 172cc7c4e96cdc34dc4041c45e6ea5e8893733d4a3c11e0173e495017800fd03
description:
  Construye un modelo de lista simple a partir de una coleccion de items. Cada item se renderiza como una fila seleccionable.

example:
  ```go
  items := []ListItem{
      {Title: "Opcion 1", Description: "Primera opcion"},
      {Title: "Opcion 2", Description: "Segunda opcion"},
  }
  model := NewList(items)
  ```

notes:
  Constructor puro para listas de seleccion simple. No permite seleccion multiple — usar NewMultiList para eso.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewList crea un modelo de lista simple a partir de items.
  func NewList(items []ListItem) ListModel {
  	// stub — implementation in devfactory/tui
  	return ListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem para seleccionar"}],"output":"ListModel: lista simple seleccionable"}


id: new_multi_list_go_tui
name: new_multi_list
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewMultiList(items []ListItem) ListModel
tags: ["tui","list","multi","constructor"]
uses_functions: []
uses_types: ["list_item_go_tui"]
returns: ["list_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_multi_list.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Variante de lista que habilita seleccion multiple con toggles por item.
content_hash: f8f97606e363475c1bf31e0d91573e7b90a594a3eb11a94e5c7a5797bf3bbe01
description:
  Construye un modelo de lista con seleccion multiple. Permite al usuario marcar varios items antes de confirmar.

example:
  ```go
  items := []ListItem{
      {Title: "Go", Description: "Lenguaje compilado"},
      {Title: "Python", Description: "Lenguaje interpretado"},
      {Title: "Rust", Description: "Lenguaje de sistemas"},
  }
  model := NewMultiList(items)
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewMultiList crea un modelo de lista con seleccion multiple.
  func NewMultiList(items []ListItem) ListModel {
  	// stub — implementation in devfactory/tui
  	return ListModel{}
  }

params_schema:
  {"params":[{"name":"items","desc":"array de ListItem para seleccionar múltiples"}],"output":"ListModel: lista con selección múltiple"}


id: new_multi_progress_go_tui
name: new_multi_progress
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewMultiProgress() MultiProgressModel
tags: ["tui","progress","multi","constructor"]
uses_functions: []
uses_types: []
returns: ["multi_progress_model_go_tui"]
imports: []
example:
  ```go
  model := NewMultiProgress()
  ```

tests: []
file_path: functions/tui/new_multi_progress.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 01b0fbee65c138ee46f9752e5b5969ec134d4c6bc407d47ce5bf813337773e2f
params_schema: {"output":"MultiProgressModel: contenedor para múltiples barras de progreso"}
description:
  Construye un modelo de progreso multiple vacio. Las barras individuales se agregan posteriormente via mensajes.

notes:
  Inicia sin barras. Cada barra se agrega dinamicamente via el loop de Bubble Tea, ideal para descargas paralelas o tareas concurrentes.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewMultiProgress crea un modelo de progreso multiple sin barras iniciales.
  func NewMultiProgress() MultiProgressModel {
  	// stub — implementation in devfactory/tui
  	return MultiProgressModel{}
  }


id: new_progress_go_tui
name: new_progress
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewProgress(total float64, label string) ProgressModel
description: Construye un modelo de barra de progreso con valor total y etiqueta descriptiva.
tags: ["tui","progress","constructor"]
uses_functions: []
uses_types: []
returns: ["progress_model_go_tui"]
imports: []
example:
  ```go
  model := NewProgress(100.0, "Descargando archivos")
  ```

tests: []
file_path: functions/tui/new_progress.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: El progreso inicia en 0. El avance se actualiza via mensajes en el loop de Bubble Tea.
content_hash: c98ec9a38d14fc41a68a44b308986b44022c76cbddd90b08e0e5c5929bdc48d3
code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewProgress crea un modelo de barra de progreso.
  func NewProgress(total float64, label string) ProgressModel {
  	// stub — implementation in devfactory/tui
  	return ProgressModel{}
  }

params_schema:
  {"params":[{"name":"total","desc":"valor total para la barra de progreso"},{"name":"label","desc":"etiqueta descriptiva de la operación"}],"output":"ProgressModel: barra de progreso"}


id: new_progress_with_styles_go_tui
name: new_progress_with_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewProgressWithStyles(total float64, label string, styles Styles) ProgressModel
tags: ["tui","progress","styles","constructor"]
uses_functions: []
uses_types: ["styles_go_tui"]
returns: ["progress_model_go_tui"]
imports: []
example:
  ```go
  styles := DarkStyles()
  model := NewProgressWithStyles(50.0, "Indexando", styles)
  ```

tests: []
file_path: functions/tui/new_progress_with_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Variante de NewProgress que permite personalizar la apariencia de la barra mediante Styles.
content_hash: 61d3a9ff3ef6c0ece4204e0b0b2dd4786772791f9a04d52bae09f7d58cc5b910
description:
  Construye un modelo de barra de progreso con valor total, etiqueta y estilos visuales personalizados.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewProgressWithStyles crea un modelo de barra de progreso con estilos personalizados.
  func NewProgressWithStyles(total float64, label string, styles Styles) ProgressModel {
  	// stub — implementation in devfactory/tui
  	return ProgressModel{}
  }

params_schema:
  {"params":[{"name":"total","desc":"valor total para la barra"},{"name":"label","desc":"etiqueta descriptiva"},{"name":"styles","desc":"Styles para aplicar a la barra"}],"output":"ProgressModel: barra de progreso estilizada"}


id: new_spinner_go_tui
name: new_spinner
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinner(message string) SpinnerModel
description: Construye un modelo de spinner basico con un mensaje descriptivo. Usa el estilo por defecto.
tags: ["tui","spinner","constructor"]
uses_functions: []
uses_types: []
returns: ["spinner_model_go_tui"]
imports: []
example:
  ```go
  model := NewSpinner("Cargando datos...")
  ```

tests: []
file_path: functions/tui/new_spinner.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Spinner con estilos por defecto. Para personalizar apariencia, usar NewSpinnerWithStyle.
content_hash: 739365ccd5816559feb7745490f5f8d37e9085d1a4f226b6d93e2adcf6cf9901
code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinner crea un modelo de spinner con un mensaje.
  func NewSpinner(message string) SpinnerModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje a mostrar junto al spinner"}],"output":"SpinnerModel: spinner con estilos por defecto"}


id: new_spinner_with_style_go_tui
name: new_spinner_with_style
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinnerWithStyle(message string, styles Styles) SpinnerModel
description: Construye un modelo de spinner con mensaje y estilos visuales personalizados.
tags: ["tui","spinner","styles","constructor"]
uses_functions: []
uses_types: ["styles_go_tui"]
returns: ["spinner_model_go_tui"]
imports: []
tests: []
file_path: functions/tui/new_spinner_with_style.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Permite controlar colores y bordes del spinner mediante el sistema de estilos del registry.
content_hash: 7bfbfb42c48bf2e86442c40ee73cffde4138dc64dd24df93c97d9433cee809cc
example:
  ```go
  theme := DarkTheme()
  styles := NewStyles(theme)
  model := NewSpinnerWithStyle("Procesando...", styles)
  ```

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinnerWithStyle crea un modelo de spinner con estilos personalizados.
  func NewSpinnerWithStyle(message string, styles Styles) SpinnerModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje a mostrar"},{"name":"styles","desc":"Styles para personalizar apariencia"}],"output":"SpinnerModel: spinner estilizado"}


id: new_spinner_with_timeout_go_tui
name: new_spinner_with_timeout
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewSpinnerWithTimeout(message string, timeout time.Duration) SpinnerWithTimeoutModel
tags: ["tui","spinner","timeout","constructor"]
uses_functions: []
uses_types: []
returns: ["spinner_with_timeout_model_go_tui"]
imports: ["time"]
example:
  ```go
  model := NewSpinnerWithTimeout("Esperando respuesta...", 30*time.Second)
  ```

tests: []
file_path: functions/tui/new_spinner_with_timeout.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 71f03e3dac9537b49c56464a99ccf23761bcd7fe5badef287c15ed3cade1652d
description:
  Construye un modelo de spinner con limite de tiempo. Si la operacion excede el timeout, el spinner se detiene automaticamente.

notes:
  El timeout es configuracion pura — el side effect de cancelar ocurre en el runtime de Bubble Tea, no en el constructor.

code:
  package tui

  import "time"

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewSpinnerWithTimeout crea un modelo de spinner que se cancela tras un timeout.
  func NewSpinnerWithTimeout(message string, timeout time.Duration) SpinnerWithTimeoutModel {
  	// stub — implementation in devfactory/tui
  	return SpinnerWithTimeoutModel{}
  }

params_schema:
  {"params":[{"name":"message","desc":"mensaje del spinner"},{"name":"timeout","desc":"duración máxima antes de auto-parada"}],"output":"SpinnerWithTimeoutModel: spinner con límite de tiempo"}


id: new_styles_go_tui
name: new_styles
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NewStyles(theme Theme) Styles
tags: ["tui","styles","constructor"]
uses_functions: []
uses_types: ["theme_go_tui"]
returns: ["styles_go_tui"]
imports: []
example:
  ```go
  theme := DefaultTheme()
  styles := NewStyles(theme)
  ```

tests: []
file_path: functions/tui/new_styles.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 126a4c74daa3cde84ea29e494ff68620bf8f5e8e8e1d29e22fc29a50a00087a0
description:
  Construye un conjunto de estilos lipgloss a partir de un tema de colores. Los estilos resultantes se aplican a todos los componentes TUI.

notes:
  Convierte un Theme (paleta de colores) en Styles (estilos lipgloss aplicables). Es el puente entre la configuracion de colores y el renderizado.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // NewStyles crea estilos a partir de un tema.
  func NewStyles(theme Theme) Styles {
  	// stub — implementation in devfactory/tui
  	return Styles{}
  }

params_schema:
  {"params":[{"name":"theme","desc":"Theme con paleta de colores"}],"output":"Styles: estilos lipgloss aplicables a componentes"}


id: normalize_terminal_output_go_tui
name: normalize_terminal_output
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func NormalizeTerminalOutput(s string) string
tags: ["tui","terminal","normalize","clean","output","ansi"]
uses_functions: ["strip_ansi_go_tui"]
uses_types: []
returns: []
imports: ["strings"]
tests: []
file_path: functions/tui/normalize_terminal_output.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 31789a0a9481db4d01ba096bd09238e4dc3f790923c50b0e0a223939c2ba6418
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/core/commands.go
description:
  Limpia output de terminal: remueve codigos ANSI, normaliza saltos de linea (CRLF/CR a LF) y elimina caracteres de control no imprimibles. Preserva tabs y newlines.

example:
  ```go
  raw := "\033[32mOK\033[0m\r\nLinea 2\x00oculto"
  clean := tui.NormalizeTerminalOutput(raw)
  // clean == "OK\nLinea 2oculto"
  ```

notes:
  Preserva `\n` y `\t` como caracteres validos. Filtra todo caracter con valor < 32 (excepto tab y newline) y DEL (127). Util para capturar output de subprocesos y mostrarlo en TUIs donde los codigos ANSI rompen el render.

code:
  package tui

  import "strings"

  // NormalizeTerminalOutput strips ANSI codes, normalizes line endings,
  // and removes non-printable control characters from terminal output.
  func NormalizeTerminalOutput(s string) string {
  	if s == "" {
  		return ""
  	}

  	clean := StripANSI(s)
  	clean = strings.ReplaceAll(clean, "\r\n", "\n")
  	clean = strings.ReplaceAll(clean, "\r", "\n")

  	var b strings.Builder
  	b.Grow(len(clean))
  	for _, r := range clean {
  		switch {
  		case r == '\n' || r == '\t':
  			b.WriteRune(r)
  		case r >= 32 && r != 127:
  			b.WriteRune(r)
  		}
  	}

  	return b.String()
  }

params_schema:
  {"params":[{"name":"s","desc":"output de terminal con posibles codigos ANSI y caracteres de control"}],"output":"string limpio con solo caracteres imprimibles, tabs y newlines"}


id: print_error_go_tui
name: print_error
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintError(msg string)
description: Imprime un mensaje con estilo de error (rojo) en stderr.
tags: ["tui","print","error","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintError("No se pudo conectar al servidor")
  ```

tests: []
file_path: functions/tui/print_error.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: cf2ee342f91339636be0f7579373b7935f8a63c32f7e5baffd74f5ccffcb96a7
notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintError imprime un mensaje con estilo de error (rojo) en stderr.
  func PrintError(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de error a imprimir"}],"output":"sin salida; imprime a stderr con estilo rojo"}


id: print_info_go_tui
name: print_info
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintInfo(msg string)
description: Imprime un mensaje con estilo informativo (cyan) en stdout.
tags: ["tui","print","info","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintInfo("Procesando 42 archivos...")
  ```

tests: []
file_path: functions/tui/print_info.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 69a49be5462410a309915fd575809952916888fdd4ec955bae296ec9d2c0a049
notes:
  Implementacion real en github.com/lucasdataproyects/devfactory/tui. Este stub existe para documentar la firma y mantener el registry.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintInfo imprime un mensaje con estilo informativo (cyan) en stdout.
  func PrintInfo(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje informativo a imprimir"}],"output":"sin salida; imprime a stdout con estilo cyan"}


id: print_muted_go_tui
name: print_muted
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintMuted(msg string)
description: Imprime un mensaje con estilo atenuado (gris) en stdout.
tags: ["tui","print","muted","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintMuted("Ultima actualizacion: hace 5 minutos")
  ```

tests: []
file_path: functions/tui/print_muted.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5c829a8809f44c668812e9a77f362f0b55975bbcfe94a2bba7160244e7b8308d
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintMuted imprime un mensaje con estilo atenuado (gris) en stdout.
  func PrintMuted(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje atenuado a imprimir"}],"output":"sin salida; imprime a stdout con estilo gris"}


id: print_success_go_tui
name: print_success
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintSuccess(msg string)
description: Imprime un mensaje con estilo de exito (verde) en stdout.
tags: ["tui","print","success","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintSuccess("Operacion completada correctamente")
  ```

tests: []
file_path: functions/tui/print_success.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: dbf59cafabb4fdbf0185841363c5b339e78d426b82fa7c04e97200692a1a4e96
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintSuccess imprime un mensaje con estilo de exito (verde) en stdout.
  func PrintSuccess(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de éxito a imprimir"}],"output":"sin salida; imprime a stdout con estilo verde"}


id: print_warning_go_tui
name: print_warning
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func PrintWarning(msg string)
description: Imprime un mensaje con estilo de advertencia (naranja) en stdout.
tags: ["tui","print","warning","output"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```go
  tui.PrintWarning("El archivo sera sobreescrito")
  ```

tests: []
file_path: functions/tui/print_warning.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b6c197a5dc678ec5aa3c325c6f5f10b6f006ebd8120a2975cc532c008266779d
notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // PrintWarning imprime un mensaje con estilo de advertencia (naranja) en stdout.
  func PrintWarning(msg string) {
  	// stub — implementation in devfactory/tui
  }

params_schema:
  {"params":[{"name":"msg","desc":"mensaje de advertencia a imprimir"}],"output":"sin salida; imprime a stdout con estilo naranja"}


id: quit_msg_go_tui
name: quit_msg
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func Quit() tea.Msg
description: Devuelve un mensaje de salida para el bucle de Bubble Tea.
tags: ["tui","quit","message","bubbletea"]
uses_functions: []
uses_types: []
returns: []
imports: ["github.com/charmbracelet/bubbletea"]
example:
  ```go
  msg := Quit()
  // msg es tea.QuitMsg{}, usado para terminar el event loop de Bubble Tea
  ```

tests: []
file_path: functions/tui/quit_msg.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 0a615073f453fd2b776ce8ce4a7c9fff380830c668e743c63f085d75a8407631
params_schema: {"output":"tea.Msg: mensaje de salida para el event loop"}
notes:
  Wrapper puro sobre tea.QuitMsg{}. Util para componer en pipelines o pasar como callback sin exponer el tipo directamente.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  import tea "github.com/charmbracelet/bubbletea"

  // Quit devuelve un mensaje de salida para el bucle de Bubble Tea.
  func Quit() tea.Msg {
  	return tea.QuitMsg{}
  }


id: read_dir_autocomplete_go_tui
name: read_dir_autocomplete
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func ReadDirAutocomplete(searchDir, prefix string) ([]AutocompleteCandidate, error)
tags: ["tui","autocomplete","directory","path","filter","completion"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","sort","strings"]
tests: []
file_path: functions/tui/read_dir_autocomplete.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 59ba75dcec8cba9bd71ab67a5790250aa15b4b0cb43b16ff5270a1743873605b
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/middleware/command_fs.go
description:
  Lee entradas de un directorio filtradas por prefijo (case-insensitive). Retorna candidatos ordenados por nombre, utiles para autocompletado de rutas en TUIs.

example:
  ```go
  candidates, err := tui.ReadDirAutocomplete("/home/user", "doc")
  // candidates podria ser: [{Name:"Documents" IsDir:true}, {Name:"docker-compose.yml" IsDir:false}]

  // Sin prefijo retorna todas las entradas
  all, _ := tui.ReadDirAutocomplete("/tmp", "")
  ```

notes:
  El struct `AutocompleteCandidate` se define en el mismo archivo. El campo `IsDir` permite al caller diferenciar directorios de archivos para renderizar iconos o sufijos distintos.

code:
  package tui

  import (
  	"os"
  	"sort"
  	"strings"
  )

  // AutocompleteCandidate represents a directory entry for path autocomplete.
  type AutocompleteCandidate struct {
  	Name  string
  	IsDir bool
  }

  // ReadDirAutocomplete reads entries from searchDir whose name starts with prefix
  // (case-insensitive). Returns candidates sorted by name.
  func ReadDirAutocomplete(searchDir, prefix string) ([]AutocompleteCandidate, error) {
  	entries, err := os.ReadDir(searchDir)
  	if err != nil {
  		return nil, err
  	}
  	prefixLower := strings.ToLower(prefix)
  	var results []AutocompleteCandidate
  	for _, e := range entries {
  		name := e.Name()
  		if prefix == "" || strings.HasPrefix(strings.ToLower(name), prefixLower) {
  			results = append(results, AutocompleteCandidate{Name: name, IsDir: e.IsDir()})
  		}
  	}
  	sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name })
  	return results, nil
  }

params_schema:
  {"params":[{"name":"searchDir","desc":"directorio donde buscar entradas"},{"name":"prefix","desc":"prefijo para filtrar nombres (case-insensitive); vacio retorna todas"}],"output":"slice de AutocompleteCandidate con Name y IsDir, ordenados alfabeticamente"}


id: run_fullscreen_go_tui
name: run_fullscreen
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunFullscreen[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea en modo fullscreen.
tags: ["tui","runner","fullscreen","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_fullscreen.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 55752626346e172a73b663cfc4b8ecf19af0d2c457fc7282808a73788ce438ea
example:
  ```go
  result := tui.RunFullscreen(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunFullscreen ejecuta un modelo Bubbletea en modo fullscreen.
  func RunFullscreen[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar"}],"output":"Result[T]: modelo final o error"}


id: run_model_go_tui
name: run_model
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunModel[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea y devuelve el modelo final o error.
tags: ["tui","runner","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_model.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8c5513003396d78c3c69865a05546414a2e4cb1086e3d2d6baf635b51c8e1e2c
example:
  ```go
  result := tui.RunModel(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunModel ejecuta un modelo Bubbletea y devuelve el modelo final.
  func RunModel[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar"}],"output":"Result[T]: modelo final o error"}


id: run_with_mouse_go_tui
name: run_with_mouse
kind: function
lang: go
domain: tui
version: 1.0.0
purity: impure
signature: func RunWithMouseSupport[T tea.Model](model T) core.Result[T]
description: Ejecuta un modelo Bubble Tea con soporte de raton habilitado.
tags: ["tui","runner","mouse","bubbletea","generic"]
uses_functions: []
uses_types: ["result_go_core"]
returns: ["result_go_core"]
error_type: error_go_core
imports: ["github.com/charmbracelet/bubbletea"]
tests: []
file_path: functions/tui/run_with_mouse.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 415a6b1ff0d325d05c5706674ee74f093642cf5e096875606002a85b93c0dda3
example:
  ```go
  result := tui.RunWithMouseSupport(myModel)
  if result.IsErr() {
      log.Fatal(result.Error())
  }
  finalModel := result.Unwrap()
  ```

notes:
[cell repeats an earlier one in this file: 133 characters]

code:
  package tui

  import tea "github.com/charmbracelet/bubbletea"

  // RunWithMouseSupport ejecuta un modelo Bubbletea con soporte de mouse.
  func RunWithMouseSupport[T tea.Model](model T) (T, error) {
  	// stub — implementation in devfactory/tui
  	var zero T
  	return zero, nil
  }

params_schema:
  {"params":[{"name":"model","desc":"modelo Bubble Tea a ejecutar con soporte de ratón"}],"output":"Result[T]: modelo final o error"}


id: show_cursor_go_tui
name: show_cursor
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func ShowCursor() string
description: Devuelve el codigo de escape ANSI para mostrar el cursor del terminal.
tags: ["tui","ansi","terminal","cursor"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```go
  fmt.Print(ShowCursor())
  // Restaura la visibilidad del cursor del terminal
  ```

tests: []
file_path: functions/tui/show_cursor.go
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 30bc8ea3a756a2b14b276efca20b8988826002c489745685cddba3504417c953
params_schema: {"output":"string: código ANSI para mostrar el cursor"}
notes:
  Usa la secuencia DECTCEM (ESC[?25h) del estandar VT220. Complemento de HideCursor. Llamar siempre al finalizar la aplicacion para restaurar el estado del terminal.

code:
  package tui

  // Implementation: github.com/lucasdataproyects/devfactory/tui

  // ShowCursor devuelve el codigo de escape ANSI para mostrar el cursor del terminal.
  func ShowCursor() string {
  	return "\033[?25h"
  }


id: strip_ansi_go_tui
name: strip_ansi
kind: function
lang: go
domain: tui
version: 1.0.0
purity: pure
signature: func StripANSI(s string) string
tags: ["tui","ansi","strip","terminal","escape","clean"]
uses_functions: []
uses_types: []
returns: []
imports: ["regexp"]
tests: []
file_path: functions/tui/strip_ansi.go
created_at: 2026-04-08T21:23:05Z
updated_at: 2026-04-08T21:23:05Z
props: []
emits: []
variant: []
content_hash: 1faffeb89609953dbf4a6f176cc3daf05f92d23cdf7699568e1c71a267114507
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: launcher/core/commands.go
description:
  Remueve secuencias de escape ANSI de un string. Util para limpiar output de terminal antes de procesarlo o mostrarlo en contextos sin soporte ANSI.

example:
  ```go
  raw := "\033[31mError:\033[0m archivo no encontrado"
  clean := tui.StripANSI(raw)
  // clean == "Error: archivo no encontrado"
  ```

notes:
  Usa regex `\x1b\[[0-9;]*[a-zA-Z]` que cubre los codigos CSI estandar (colores, cursor, etc.). No cubre secuencias OSC ni DCS menos comunes.

code:
  package tui

  import "regexp"

  // ansiEscape matches ANSI terminal escape sequences.
  var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)

  // StripANSI removes ANSI terminal escape sequences from s.
  func StripANSI(s string) string {
  	return ansiEscape.ReplaceAllString(s, "")
  }

params_schema:
  {"params":[{"name":"s","desc":"string con posibles secuencias de escape ANSI"}],"output":"string sin secuencias ANSI"}


id: analyze_dns_bash_cybersecurity
name: analyze_dns
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: analyze_dns(domain: string, mode: string) -> void
tags: ["bash","cybersecurity","dns","network","whois","dnsbl","reconnaissance"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/analyze_dns.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 2b3e7a51a50963830121dcffce57ad8c05dbf4af86b4f1b02b5ba34104821754
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/redes/analisis_dns.sh
description:
  Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA, consulta whois y verificación contra listas negras DNSBL (spamhaus, spamcop, sorbs, barracuda).

example:
  ```bash
  source bash/functions/cybersecurity/analyze_dns.sh

  # Análisis completo
  analyze_dns example.com

  # Solo registros DNS
  analyze_dns example.com records

  # Solo whois
  analyze_dns example.com whois

  # Solo DNSBL
  analyze_dns example.com dnsbl
  ```

notes:
  Requiere `dig` (paquete dnsutils). `whois` es opcional — si no está instalado y el modo es `all`, se omite el paso whois con aviso. Las listas negras DNSBL se consultan via DNS inverso (técnica estándar sin HTTP). El modo `dnsbl` resuelve primero la IP del dominio y luego construye la consulta invertida para cada blacklist.

code:
  #!/usr/bin/env bash
  # analyze_dns
  # -----------
  # Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA,
  # consulta whois y verificación contra listas negras DNSBL.
  #
  # USO (directo):
  #   analyze_dns example.com [records|whois|dnsbl|all]
  #
  # Depende de: dig, whois (opcional), curl (para DNSBL)

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../shell/bash_colors.sh"
  source "$SCRIPT_DIR/../shell/bash_log.sh"
  bash_colors
  bash_log_init

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _dns_is_valid_domain() {
      local domain="$1"
      [[ -n "$domain" && "$domain" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
  }

  _dns_build_dnsbl_query() {
      local ip="$1"
      local bl="$2"
      local reversed
      reversed="$(echo "$ip" | awk -F. '{print $4"."$3"."$2"."$1}')"
      echo "${reversed}.${bl}"
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _dns_query_record() {
      local domain="$1"
      local type="$2"
      local result
      result="$(dig +short "$type" "$domain" 2>/dev/null || true)"
      if [[ -z "$result" ]]; then
          echo "  (sin registros)"
      else
          echo "$result" | while IFS= read -r line; do
              echo "  * $line"
          done
      fi
  }

  _dns_show_all_records() {
      local domain="$1"
      echo ""
      for type in A AAAA MX NS TXT CNAME SOA; do
          echo -e "${CYAN}── ${type} ──────────────────${NC}"
          _dns_query_record "$domain" "$type"
          echo ""
      done
  }

  _dns_show_whois() {
      local domain="$1"
      echo ""
      info "Consultando whois de ${domain}..."
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
      whois "$domain" 2>/dev/null \
          | grep -iE "(registrar|registrant|creation|expiry|expire|updated|name server|status)" \
          | head -20 \
          | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
  }

  _dns_check_dnsbl() {
      local domain="$1"
      local ip
      ip="$(dig +short A "$domain" 2>/dev/null | head -1 || true)"

      if [[ -z "$ip" ]]; then
          warning "No se pudo resolver la IP de $domain para comprobar DNSBL"
          return
      fi

      info "IP a comprobar: $ip"
      echo ""

      local blacklists=(
          "zen.spamhaus.org"
          "bl.spamcop.net"
          "dnsbl.sorbs.net"
          "b.barracudacentral.org"
      )

      local found=0
      for bl in "${blacklists[@]}"; do
          local query
          query="$(_dns_build_dnsbl_query "$ip" "$bl")"
          local result
          result="$(dig +short A "$query" 2>/dev/null || true)"
          if [[ -n "$result" ]]; then
              echo -e "  ${RED}LISTADO${NC} ${bl} ($result)"
              found=$((found + 1))
          else
              echo -e "  ${GREEN}limpio${NC}  ${bl}"
          fi
      done

      echo ""
      if [[ $found -eq 0 ]]; then
          success "La IP no aparece en ninguna lista negra comprobada"
      else
          warning "La IP aparece en ${found} lista(s) negra(s)"
      fi
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  analyze_dns() {
      local domain="$1"
      local mode="${2:-all}"

      if [[ -z "$domain" ]]; then
          error "analyze_dns: se requiere un dominio como primer argumento" >&2
          return 1
      fi

      if ! _dns_is_valid_domain "$domain"; then
          error "analyze_dns: dominio no válido: '$domain'" >&2
          return 1
      fi

      if ! command -v dig &>/dev/null; then
          error "analyze_dns: 'dig' no está instalado (sudo apt install dnsutils)" >&2
          return 1
      fi

      info "Analizando: ${domain}"

      case "$mode" in
          records)
              _dns_show_all_records "$domain"
              ;;
          whois)
              if ! command -v whois &>/dev/null; then
                  error "analyze_dns: 'whois' no está instalado (sudo apt install whois)" >&2
                  return 1
              fi
              _dns_show_whois "$domain"
              ;;
          dnsbl)
              _dns_check_dnsbl "$domain"
              ;;
          all)
              _dns_show_all_records "$domain"
              if command -v whois &>/dev/null; then
                  _dns_show_whois "$domain"
              else
                  warning "whois no disponible, omitiendo"
              fi
              echo ""
              _dns_check_dnsbl "$domain"
              ;;
          *)
              error "analyze_dns: modo no válido '$mode'. Use: records|whois|dnsbl|all" >&2
              return 1
              ;;
      esac
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      analyze_dns "$@"
  fi

params_schema:
  {"params":[{"name":"domain","desc":"dominio a analizar, ej: example.com"},{"name":"mode","desc":"modo de análisis: records (solo registros DNS), whois (solo whois), dnsbl (solo listas negras) o all (todo, por defecto)"}],"output":"imprime registros DNS, información whois y estado DNSBL a stdout con colores ANSI"}


id: audit_http_headers_bash_cybersecurity
name: audit_http_headers
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: audit_http_headers(url: string) -> void
tags: ["bash","cybersecurity","web","http","headers","security","hsts","csp","hardening"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/audit_http_headers.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: e8142f482318ce43c2841205853394ecf0266154bf207222e93c225e001f264a
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/web/cabeceras_http.sh
description:
  Audita las cabeceras HTTP de seguridad de una URL: verifica la presencia de HSTS (con validación de max-age mínimo de 6 meses), Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y cabeceras CORS. También detecta cabeceras que exponen información del servidor.

example:
  ```bash
  source bash/functions/cybersecurity/audit_http_headers.sh

  # Con URL completa
  audit_http_headers https://example.com

  # Sin esquema (añade https:// automáticamente)
  audit_http_headers example.com

  # Seguir redirecciones
  audit_http_headers http://example.com
  ```

notes:
  Usa `curl -sI --location` para seguir redirecciones y obtener solo cabeceras. El check de HSTS valida que `max-age` sea >= 15.768.000 segundos (6 meses), valor mínimo recomendado por OWASP. Las cabeceras Server, X-Powered-By, X-AspNet-Version y X-Generator se marcan como advertencia por revelar información del stack tecnológico. Timeout de 15 segundos para evitar cuelgues.

code:
  #!/usr/bin/env bash
  # audit_http_headers
  # ------------------
  # Audita las cabeceras HTTP de seguridad de una URL: HSTS, CSP, X-Frame-Options,
  # X-Content-Type-Options, Referrer-Policy, Permissions-Policy y otras.
  # También muestra cabeceras que exponen información del servidor.
  #
  # USO (directo):
  #   audit_http_headers <url>
  #
  # Depende de: curl

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../shell/bash_colors.sh"
  source "$SCRIPT_DIR/../shell/bash_log.sh"
  bash_colors
  bash_log_init

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _hdr_normalize_url() {
      local url="$1"
      if [[ ! "$url" =~ ^https?:// ]]; then
          echo "https://${url}"
      else
          echo "$url"
      fi
  }

  _hdr_header_present() {
      local headers="$1"
      local name="$2"
      echo "$headers" | grep -qi "^${name}:"
  }

  _hdr_extract_value() {
      local headers="$1"
      local name="$2"
      echo "$headers" | grep -i "^${name}:" | cut -d: -f2- | xargs
  }

  _hdr_hsts_is_strong() {
      local value="$1"
      local max_age
      max_age="$(echo "$value" | grep -oE 'max-age=[0-9]+' | cut -d= -f2 || echo 0)"
      [[ "$max_age" -ge 15768000 ]]  # 6 meses en segundos
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _hdr_fetch() {
      local url="$1"
      curl -sI --max-time 15 --location "$url" 2>/dev/null | tr -d '\r'
  }

  _hdr_check_header() {
      local headers="$1"
      local name="$2"
      local description="$3"

      if _hdr_header_present "$headers" "$name"; then
          local value
          value="$(_hdr_extract_value "$headers" "$name")"
          echo -e "  ${GREEN}[ok]${NC} ${name}"
          echo -e "       ${GRAY}${value}${NC}"
      else
          echo -e "  ${RED}[x] ${NC} ${name} -- ${description}"
      fi
  }

  _hdr_check_hsts() {
      local headers="$1"
      local name="Strict-Transport-Security"
      if _hdr_header_present "$headers" "$name"; then
          local value
          value="$(_hdr_extract_value "$headers" "$name")"
          if _hdr_hsts_is_strong "$value"; then
              echo -e "  ${GREEN}[ok]${NC} ${name}"
          else
              echo -e "  ${YELLOW}[!] ${NC} ${name} -- max-age demasiado corto (<6 meses)"
          fi
          echo -e "       ${GRAY}${value}${NC}"
      else
          echo -e "  ${RED}[x] ${NC} ${name} -- HSTS no configurado"
      fi
  }

  _hdr_show_server_info() {
      local headers="$1"

      echo ""
      echo -e "${CYAN}── Información del servidor ──────────────────────${NC}"

      for h in Server X-Powered-By X-AspNet-Version X-Generator; do
          if _hdr_header_present "$headers" "$h"; then
              local val
              val="$(_hdr_extract_value "$headers" "$h")"
              echo -e "  ${YELLOW}[!]${NC} ${h}: ${val} (información expuesta)"
          fi
      done

      local status
      status="$(echo "$headers" | head -1)"
      echo -e "  ${CYAN}Status:${NC} ${status}"
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  audit_http_headers() {
      local raw_url="$1"

      if [[ -z "$raw_url" ]]; then
          error "audit_http_headers: se requiere una URL como argumento" >&2
          return 1
      fi

      if ! command -v curl &>/dev/null; then
          error "audit_http_headers: 'curl' no está instalado (sudo apt install curl)" >&2
          return 1
      fi

      local url
      url="$(_hdr_normalize_url "$raw_url")"

      info "Consultando cabeceras de: ${url}"
      local headers
      headers="$(_hdr_fetch "$url")"

      if [[ -z "$headers" ]]; then
          error "audit_http_headers: no se pudieron obtener las cabeceras. ¿El sitio está disponible?" >&2
          return 1
      fi

      echo ""
      echo -e "${PURPLE}════════ Cabeceras de Seguridad ════════════════${NC}"
      echo ""

      _hdr_check_hsts "$headers"
      _hdr_check_header "$headers" "Content-Security-Policy"      "Previene XSS e inyección de contenido"
      _hdr_check_header "$headers" "X-Frame-Options"              "Previene clickjacking"
      _hdr_check_header "$headers" "X-Content-Type-Options"       "Previene MIME sniffing"
      _hdr_check_header "$headers" "Referrer-Policy"              "Controla información del referrer"
      _hdr_check_header "$headers" "Permissions-Policy"           "Controla acceso a APIs del navegador"
      _hdr_check_header "$headers" "Cross-Origin-Opener-Policy"   "Aísla el contexto de navegación"
      _hdr_check_header "$headers" "Cross-Origin-Resource-Policy" "Controla compartición de recursos"

      _hdr_show_server_info "$headers"
      echo ""
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      audit_http_headers "$@"
  fi

params_schema:
  {"params":[{"name":"url","desc":"URL del sitio web a auditar; si no tiene esquema se añade https:// automáticamente"}],"output":"imprime el estado de cada cabecera de seguridad (ok/falta/advertencia), el valor de las presentes y cabeceras que exponen información del servidor"}


id: audit_ssh_config_bash_cybersecurity
name: audit_ssh_config
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: audit_ssh_config(config_path: string) -> void
tags: ["bash","cybersecurity","ssh","audit","security","hardening","linux"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/audit_ssh_config.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 5fd5d795a15c4a7adf03f0695684a3cd67b5e5a3f448b78566dbe22bcce64e96
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/sistema/auditar_ssh.sh
description:
  Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual.

example:
  ```bash
  source bash/functions/cybersecurity/audit_ssh_config.sh

  # Auditar la configuración por defecto
  audit_ssh_config

  # Auditar un archivo alternativo
  audit_ssh_config /etc/ssh/sshd_config.d/custom.conf
  ```

notes:
  Los logs de intentos fallidos se buscan primero en `journalctl` (systemd) y si no está disponible en `/var/log/auth.log`. Leer `/etc/ssh/sshd_config` puede requerir permisos de root en algunos sistemas. Los criterios de evaluación siguen las recomendaciones de CIS Benchmark para SSH: PermitRootLogin=no, PasswordAuthentication=no, MaxAuthTries<=3.

code:
  #!/usr/bin/env bash
  # audit_ssh_config
  # ----------------
  # Audita la configuración de sshd_config evaluando parámetros de seguridad,
  # revisa intentos de login fallidos y lista las claves autorizadas del usuario.
  #
  # USO (directo):
  #   audit_ssh_config [/ruta/a/sshd_config]
  #
  # Depende de: grep, ssh (opcional para validación), journalctl o /var/log/auth.log

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../shell/bash_colors.sh"
  source "$SCRIPT_DIR/../shell/bash_log.sh"
  bash_colors
  bash_log_init

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _ssh_get_value() {
      local config="$1"
      local key="$2"
      grep -iE "^[[:space:]]*${key}[[:space:]]" "$config" 2>/dev/null \
          | tail -1 | awk '{print $2}' | xargs
  }

  _ssh_eval_permit_root() {
      local val="${1:-yes}"
      case "${val,,}" in
          no|prohibit-password) echo "ok" ;;
          without-password)     echo "warn" ;;
          *)                    echo "bad" ;;
      esac
  }

  _ssh_eval_password_auth() {
      local val="${1:-yes}"
      [[ "${val,,}" == "no" ]] && echo "ok" || echo "bad"
  }

  _ssh_eval_max_auth_tries() {
      local val="${1:-6}"
      [[ "$val" -le 3 ]] && echo "ok" || echo "warn"
  }

  _ssh_eval_x11_forwarding() {
      local val="${1:-no}"
      [[ "${val,,}" == "no" ]] && echo "ok" || echo "warn"
  }

  # ─── Funciones de presentación ────────────────────────────────────────────────

  _ssh_print_check() {
      local level="$1"
      local label="$2"
      local value="$3"
      local note="$4"

      case "$level" in
          ok)   echo -e "  ${GREEN}[ok]${NC} ${label}: ${value}" ;;
          warn) echo -e "  ${YELLOW}[!] ${NC} ${label}: ${value} -- ${note}" ;;
          bad)  echo -e "  ${RED}[x] ${NC} ${label}: ${value} -- ${note}" ;;
      esac
  }

  _ssh_show_config_checks() {
      local config="$1"

      echo -e "${PURPLE}════════ Configuración sshd_config ════════════${NC}"
      echo ""

      local permit_root
      permit_root="$(_ssh_get_value "$config" "PermitRootLogin")"
      permit_root="${permit_root:-yes (por defecto)}"
      _ssh_print_check "$(_ssh_eval_permit_root "$permit_root")" \
          "PermitRootLogin" "$permit_root" "debería ser 'no' o 'prohibit-password'"

      local pass_auth
      pass_auth="$(_ssh_get_value "$config" "PasswordAuthentication")"
      pass_auth="${pass_auth:-yes (por defecto)}"
      _ssh_print_check "$(_ssh_eval_password_auth "$pass_auth")" \
          "PasswordAuthentication" "$pass_auth" "debería ser 'no' (usar claves)"

      local port
      port="$(_ssh_get_value "$config" "Port")"
      port="${port:-22 (por defecto)}"
      if [[ "$port" == "22"* ]]; then
          _ssh_print_check "warn" "Port" "$port" "considera cambiar el puerto 22"
      else
          _ssh_print_check "ok" "Port" "$port" ""
      fi

      local max_tries
      max_tries="$(_ssh_get_value "$config" "MaxAuthTries")"
      max_tries="${max_tries:-6 (por defecto)}"
      _ssh_print_check "$(_ssh_eval_max_auth_tries "${max_tries%% *}")" \
          "MaxAuthTries" "$max_tries" "recomendado <= 3"

      local x11
      x11="$(_ssh_get_value "$config" "X11Forwarding")"
      x11="${x11:-no (por defecto)}"
      _ssh_print_check "$(_ssh_eval_x11_forwarding "$x11")" \
          "X11Forwarding" "$x11" "deshabilitar si no se usa"

      local allow_users allow_groups
      allow_users="$(_ssh_get_value "$config" "AllowUsers")"
      allow_groups="$(_ssh_get_value "$config" "AllowGroups")"
      if [[ -z "$allow_users" && -z "$allow_groups" ]]; then
          _ssh_print_check "warn" "AllowUsers/AllowGroups" "(no definidos)" "considera restringir acceso por usuario o grupo"
      else
          [[ -n "$allow_users"  ]] && _ssh_print_check "ok" "AllowUsers"  "$allow_users" ""
          [[ -n "$allow_groups" ]] && _ssh_print_check "ok" "AllowGroups" "$allow_groups" ""
      fi

      echo ""
  }

  _ssh_show_failed_logins() {
      echo -e "${PURPLE}════════ Últimos intentos de login fallidos ════${NC}"
      echo ""

      if command -v journalctl &>/dev/null; then
          journalctl -u ssh -u sshd --no-pager -q 2>/dev/null \
              | grep -i "failed\|invalid\|error" | tail -10 \
              | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done || true
      elif [[ -f /var/log/auth.log ]]; then
          grep -i "failed\|invalid" /var/log/auth.log 2>/dev/null | tail -10 \
              | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done || true
      else
          info "No se encontró fuente de logs de autenticación"
      fi

      echo ""
  }

  _ssh_show_authorized_keys() {
      echo -e "${PURPLE}════════ Claves autorizadas (~/.ssh) ═══════════${NC}"
      echo ""

      local auth_keys="$HOME/.ssh/authorized_keys"
      if [[ -f "$auth_keys" ]]; then
          local count
          count="$(wc -l < "$auth_keys")"
          info "${count} clave(s) en authorized_keys:"
          while IFS= read -r line; do
              [[ -z "$line" || "$line" == "#"* ]] && continue
              local key_type key_comment
              key_type="$(echo "$line" | awk '{print $1}')"
              key_comment="$(echo "$line" | awk '{print $NF}')"
              echo -e "  ${GREEN}*${NC} ${key_type} -- ${key_comment}"
          done < "$auth_keys"
      else
          info "No existe $auth_keys"
      fi

      echo ""
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  audit_ssh_config() {
      local config_path="${1:-/etc/ssh/sshd_config}"

      if [[ ! -f "$config_path" ]]; then
          warning "audit_ssh_config: no se encontró $config_path -- ¿está instalado sshd?"
      else
          _ssh_show_config_checks "$config_path"
      fi

      _ssh_show_failed_logins
      _ssh_show_authorized_keys
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      audit_ssh_config "$@"
  fi

params_schema:
  {"params":[{"name":"config_path","desc":"ruta al archivo sshd_config a auditar (por defecto: /etc/ssh/sshd_config)"}],"output":"imprime checks con nivel ok/warn/bad para cada parámetro, últimos 10 intentos de login fallidos y lista de claves autorizadas"}


id: check_firewall_bash_cybersecurity
name: check_firewall
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: check_firewall() -> void
tags: ["bash","cybersecurity","firewall","ufw","iptables","network","hardening","linux"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source bash/functions/cybersecurity/check_firewall.sh

  check_firewall
  ```

tests: []
file_path: bash/functions/cybersecurity/check_firewall.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 377ff4fd45bef429799b167246c2a01b6053099b7c98b00deca5a90aa52118d3
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/sistema/firewall_status.sh
description:
  Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra su estado, reglas activas y puertos en escucha para cruzar con las reglas. Si no se detecta ningún firewall, emite una advertencia de exposición.

notes:
  La detección sigue el orden: ufw > firewalld > iptables > none. Para ufw muestra `ufw status verbose`; para firewalld muestra zona por defecto, servicios y puertos permitidos; para iptables muestra las cadenas INPUT/OUTPUT/FORWARD. Leer reglas de iptables requiere privilegios de root. El cruce de puertos en escucha (via `ss -tlnp`) ayuda a identificar servicios sin regla de firewall correspondiente.

code:
  #!/usr/bin/env bash
  # check_firewall
  # --------------
  # Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra
  # su estado y reglas. También lista los puertos en escucha para cruzar con reglas.
  #
  # USO (directo):
  #   check_firewall
  #
  # Depende de: ufw, firewall-cmd o iptables (el que esté disponible), ss

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../shell/bash_colors.sh"
  source "$SCRIPT_DIR/../shell/bash_log.sh"
  bash_colors
  bash_log_init

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _fw_detect() {
      if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status:"; then
          echo "ufw"
      elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q "running"; then
          echo "firewalld"
      elif command -v iptables &>/dev/null; then
          echo "iptables"
      else
          echo "none"
      fi
  }

  _fw_ufw_is_active() {
      ufw status 2>/dev/null | grep -q "Status: active"
  }

  _fw_firewalld_is_running() {
      firewall-cmd --state 2>/dev/null | grep -q "running"
  }

  _fw_iptables_has_rules() {
      local count
      count="$(iptables -L INPUT --line-numbers 2>/dev/null | grep -c "^[0-9]" || echo 0)"
      [[ "$count" -gt 0 ]]
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _fw_show_ufw() {
      echo -e "${PURPLE}════════ UFW ════════════════════════════════════${NC}"
      echo ""

      if _fw_ufw_is_active; then
          success "UFW está activo"
      else
          echo -e "  ${RED}[x]${NC} UFW está INACTIVO"
      fi

      echo ""
      info "Reglas activas:"
      ufw status verbose 2>/dev/null | while IFS= read -r line; do
          echo -e "  ${DIM_GRAY}${line}${NC}"
      done

      echo ""
  }

  _fw_show_firewalld() {
      echo -e "${PURPLE}════════ FirewallD ══════════════════════════════${NC}"
      echo ""

      if _fw_firewalld_is_running; then
          success "firewalld está activo"
      else
          echo -e "  ${RED}[x]${NC} firewalld está INACTIVO"
      fi

      echo ""
      local zone
      zone="$(firewall-cmd --get-default-zone 2>/dev/null || echo "desconocida")"
      info "Zona por defecto: ${zone}"
      echo ""

      info "Servicios permitidos en zona ${zone}:"
      firewall-cmd --zone="$zone" --list-services 2>/dev/null \
          | tr ' ' '\n' | while IFS= read -r svc; do
              echo -e "  ${GREEN}*${NC} ${svc}"
          done

      echo ""
      info "Puertos permitidos:"
      firewall-cmd --zone="$zone" --list-ports 2>/dev/null \
          | tr ' ' '\n' | while IFS= read -r port; do
              [[ -n "$port" ]] && echo -e "  ${YELLOW}*${NC} ${port}"
          done || true

      echo ""
  }

  _fw_show_iptables() {
      echo -e "${PURPLE}════════ iptables ═══════════════════════════════${NC}"
      echo ""

      for chain in INPUT OUTPUT FORWARD; do
          echo -e "${CYAN}── ${chain} ──${NC}"
          iptables -L "$chain" --line-numbers -n 2>/dev/null \
              | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done
          echo ""
      done

      if ! _fw_iptables_has_rules; then
          echo -e "  ${YELLOW}[!]${NC} No hay reglas INPUT definidas -- el sistema puede estar sin filtrar tráfico"
      fi
  }

  _fw_show_none() {
      echo ""
      echo -e "  ${RED}[x]${NC} No se detectó ningún firewall activo (ufw, firewalld, iptables)"
      echo -e "  ${YELLOW}[!]${NC} El sistema puede estar completamente expuesto"
      echo ""
      info "Para instalar y activar ufw: sudo apt install ufw && sudo ufw enable"
  }

  _fw_show_listening_crosscheck() {
      echo ""
      echo -e "${PURPLE}════════ Puertos en escucha (para cruzar con reglas) ════${NC}"
      echo ""
      ss -tlnp 2>/dev/null | tail -n +2 | while IFS= read -r line; do
          echo -e "  ${DIM_GRAY}${line}${NC}"
      done
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  check_firewall() {
      local fw
      fw="$(_fw_detect)"

      info "Firewall detectado: ${fw}"
      echo ""

      case "$fw" in
          ufw)       _fw_show_ufw ;;
          firewalld) _fw_show_firewalld ;;
          iptables)  _fw_show_iptables ;;
          none)      _fw_show_none ;;
      esac

      _fw_show_listening_crosscheck
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      check_firewall "$@"
  fi

params_schema:
  {"output":"imprime el firewall detectado, su estado (activo/inactivo), reglas vigentes y lista de puertos en escucha"}


id: detect_suspicious_users_bash_cybersecurity
name: detect_suspicious_users
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: detect_suspicious_users() -> void
tags: ["bash","cybersecurity","users","audit","linux","privilege-escalation","hardening"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source bash/functions/cybersecurity/detect_suspicious_users.sh

  detect_suspicious_users
  ```

tests: []
file_path: bash/functions/cybersecurity/detect_suspicious_users.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: aa0b237620aaf96d89d9a9791cbbdf32157f1c4fff87c2f77502a2827deb533f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/sistema/usuarios_sospechosos.sh
description:
  Revisa el sistema en busca de indicadores de compromiso en cuentas de usuario: UIDs 0 extras (además de root), usuarios con shell de login válida, homes en rutas inusuales, miembros de grupos privilegiados (sudo, docker, wheel, adm, etc.) y sesiones activas.

notes:
  Los usuarios de sistema (UID <= 999) se excluyen del check de shell válida para evitar falsos positivos. Los grupos privilegiados monitorizados son: sudo, wheel, docker, adm, lxd, libvirt, kvm, disk, shadow. Homes inusuales son aquellos fuera de /home, /root, /var, /srv, /nonexistent y /tmp. `lastlog` puede no estar disponible en todas las distribuciones.

code:
  #!/usr/bin/env bash
  # detect_suspicious_users
  # -----------------------
  # Revisa el sistema en busca de usuarios potencialmente sospechosos:
  # UIDs 0 extras, shells válidas, homes en rutas inusuales, grupos privilegiados
  # y sesiones activas.
  #
  # USO (directo):
  #   detect_suspicious_users
  #
  # Depende de: /etc/passwd, getent, w, lastlog

  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  source "$SCRIPT_DIR/../shell/bash_colors.sh"
  source "$SCRIPT_DIR/../shell/bash_log.sh"
  bash_colors
  bash_log_init

  # ─── Constantes ───────────────────────────────────────────────────────────────

  _SUSPICIOUS_VALID_SHELLS=("/bin/bash" "/bin/sh" "/bin/zsh" "/bin/fish" "/usr/bin/bash" "/usr/bin/zsh" "/usr/bin/fish")
  _SUSPICIOUS_PRIVILEGED_GROUPS=("sudo" "wheel" "docker" "adm" "lxd" "libvirt" "kvm" "disk" "shadow")
  _SUSPICIOUS_SYSTEM_USERS_MAX_UID=999

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _sus_is_valid_shell() {
      local shell="$1"
      for s in "${_SUSPICIOUS_VALID_SHELLS[@]}"; do
          [[ "$shell" == "$s" ]] && return 0
      done
      return 1
  }

  _sus_is_system_user() {
      local uid="$1"
      [[ "$uid" -le $_SUSPICIOUS_SYSTEM_USERS_MAX_UID ]]
  }

  _sus_is_unusual_home() {
      local home="$1"
      [[ ! "$home" =~ ^(/home|/root|/var|/srv|/nonexistent|/tmp) ]]
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _sus_show_uid0_users() {
      echo -e "${PURPLE}════════ Usuarios con UID 0 (root) ═════════════${NC}"
      echo ""

      local found=0
      while IFS=: read -r username _ uid _; do
          if [[ "$uid" -eq 0 ]]; then
              if [[ "$username" == "root" ]]; then
                  echo -e "  ${GREEN}[ok]${NC} root (esperado)"
              else
                  echo -e "  ${RED}[x]${NC} ${username} tiene UID 0 -- SOSPECHOSO"
              fi
              found=$((found + 1))
          fi
      done < /etc/passwd

      if [[ $found -eq 1 ]]; then
          echo ""
          success "Solo root tiene UID 0"
      fi

      echo ""
  }

  _sus_show_users_with_shell() {
      echo -e "${PURPLE}════════ Usuarios con shell de login válida ═════${NC}"
      echo ""
      echo -e "  ${GRAY}(excluye usuarios de sistema con UID <= ${_SUSPICIOUS_SYSTEM_USERS_MAX_UID})${NC}"
      echo ""

      local found=0
      while IFS=: read -r username _ uid _ _ home shell; do
          if ! _sus_is_system_user "$uid" && _sus_is_valid_shell "$shell"; then
              echo -e "  ${CYAN}*${NC} ${username} (UID ${uid}) -- shell: ${shell} -- home: ${home}"
              found=$((found + 1))
          fi
      done < /etc/passwd

      [[ $found -eq 0 ]] && info "No se encontraron usuarios normales con shell válida"
      echo ""
  }

  _sus_show_unusual_homes() {
      echo -e "${PURPLE}════════ Usuarios con home inusual ══════════════${NC}"
      echo ""

      local found=0
      while IFS=: read -r username _ uid _ _ home shell; do
          if _sus_is_valid_shell "$shell" && _sus_is_unusual_home "$home"; then
              echo -e "  ${YELLOW}[!]${NC} ${username} -- home: ${home}"
              found=$((found + 1))
          fi
      done < /etc/passwd

      [[ $found -eq 0 ]] && success "No se detectaron homes en rutas inusuales"
      echo ""
  }

  _sus_show_privileged_groups() {
      echo -e "${PURPLE}════════ Grupos privilegiados y sus miembros ════${NC}"
      echo ""

      for group in "${_SUSPICIOUS_PRIVILEGED_GROUPS[@]}"; do
          local members
          members="$(getent group "$group" 2>/dev/null | cut -d: -f4 || true)"
          if [[ -n "$members" ]]; then
              echo -e "  ${YELLOW}*${NC} ${group}: ${members}"
          fi
      done

      echo ""
  }

  _sus_show_active_sessions() {
      echo -e "${PURPLE}════════ Sesiones activas ═══════════════════════${NC}"
      echo ""

      w 2>/dev/null | while IFS= read -r line; do
          echo -e "  ${DIM_GRAY}${line}${NC}"
      done

      echo ""
  }

  _sus_show_last_logins() {
      echo -e "${PURPLE}════════ Últimos logins por usuario ═════════════${NC}"
      echo ""

      if command -v lastlog &>/dev/null; then
          lastlog 2>/dev/null | awk 'NR==1 || $NF != "logged" {
              if (NR==1 || $2 != "**Never") printf "  %-16s %-10s %s\n", $1, $2, $NF
          }' | grep -v "^$" | while IFS= read -r line; do
              echo -e "  ${DIM_GRAY}${line}${NC}"
          done
      else
          warning "lastlog no disponible"
      fi

      echo ""
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  detect_suspicious_users() {
      _sus_show_uid0_users
      _sus_show_users_with_shell
      _sus_show_unusual_homes
      _sus_show_privileged_groups
      _sus_show_last_logins
      _sus_show_active_sessions
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      detect_suspicious_users "$@"
  fi

params_schema:
  {"output":"imprime secciones con UIDs 0, usuarios con shell, homes inusuales, grupos privilegiados, últimos logins y sesiones activas"}


