# functions

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: 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: 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: 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: 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: 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-13T00:00:26Z
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-13T00:00:26Z
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"}


id: encrypt_file_bash_cybersecurity
name: encrypt_file
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: encrypt_file(mode: string, file: string) -> void
tags: ["bash","cybersecurity","encryption","aes256","openssl","crypto","pbkdf2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/encrypt_file.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 9eef7b1fee807520b84fe9784ef5231c0a4f1b499774a1ee0108862c2cf87975
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/utilidades/cifrar_archivo.sh
description:
  Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones) via openssl. La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita interactivamente. El archivo cifrado se guarda con extensión .enc; al descifrar se recupera el nombre original.

example:
  ```bash
  source bash/functions/cybersecurity/encrypt_file.sh

  # Cifrar (solicita contraseña interactivamente)
  encrypt_file encrypt documento.pdf

  # Descifrar
  encrypt_file decrypt documento.pdf.enc

  # Con contraseña via variable de entorno (no interactivo)
  ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file encrypt datos.tar.gz
  ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file decrypt datos.tar.gz.enc
  ```

notes:
  Usa `openssl enc -aes-256-cbc -pbkdf2 -iter 310000` — compatible con OpenSSL 1.1.1+. Las 310.000 iteraciones de PBKDF2 siguen las recomendaciones NIST para derivación de claves en 2024. La contraseña se limpia de memoria al terminar. Si el archivo de salida ya existe, la función falla silenciosamente (no sobrescribe por seguridad cuando se usa con ENCRYPT_PASSWORD).

code:
  #!/usr/bin/env bash
  # encrypt_file
  # ------------
  # Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones).
  # La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita
  # interactivamente por stdin.
  #
  # USO (directo):
  #   encrypt_file encrypt <archivo>
  #   encrypt_file decrypt <archivo.enc>
  #   ENCRYPT_PASSWORD=secreto encrypt_file encrypt archivo.txt
  #
  # Depende de: openssl

  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 ──────────────────────────────────────────────────────────

  _enc_output_path_encrypt() {
      local input="$1"
      echo "${input}.enc"
  }

  _enc_output_path_decrypt() {
      local input="$1"
      if [[ "$input" == *.enc ]]; then
          echo "${input%.enc}"
      else
          echo "${input}.dec"
      fi
  }

  _enc_human_size() {
      local file="$1"
      du -sh "$file" 2>/dev/null | cut -f1
  }

  # ─── Funciones de efecto ──────────────────────────────────────────name──────────

  _enc_ask_password() {
      local pass
      read -rsp "Contraseña: " pass
      echo ""  >&2
      echo "$pass"
  }

  _enc_ask_password_confirm() {
      local pass1 pass2
      read -rsp "Contraseña: " pass1
      echo "" >&2
      read -rsp "Confirmar contraseña: " pass2
      echo "" >&2

      if [[ "$pass1" != "$pass2" ]]; then
          error "Las contraseñas no coinciden" >&2
          return 1
      fi

      if [[ ${#pass1} -lt 8 ]]; then
          warning "La contraseña es muy corta (mínimo 8 caracteres recomendado)"
      fi

      echo "$pass1"
  }

  _enc_do_encrypt() {
      local input="$1"
      local output="$2"
      local password="$3"

      openssl enc -aes-256-cbc -pbkdf2 -iter 310000 \
          -in "$input" -out "$output" \
          -pass "pass:${password}" 2>/dev/null
  }

  _enc_do_decrypt() {
      local input="$1"
      local output="$2"
      local password="$3"

      openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 \
          -in "$input" -out "$output" \
          -pass "pass:${password}" 2>/dev/null
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  encrypt_file() {
      local mode="$1"
      local file="$2"

      if [[ -z "$mode" || -z "$file" ]]; then
          error "encrypt_file: uso: encrypt_file <encrypt|decrypt> <archivo>" >&2
          return 1
      fi

      if ! command -v openssl &>/dev/null; then
          error "encrypt_file: 'openssl' no está instalado (sudo apt install openssl)" >&2
          return 1
      fi

      if [[ ! -f "$file" ]]; then
          error "encrypt_file: archivo no encontrado: $file" >&2
          return 1
      fi

      local password
      if [[ -n "${ENCRYPT_PASSWORD:-}" ]]; then
          password="$ENCRYPT_PASSWORD"
      else
          case "$mode" in
              encrypt) password="$(_enc_ask_password_confirm)" || return 1 ;;
              decrypt) password="$(_enc_ask_password)" ;;
              *)
                  error "encrypt_file: modo no válido '$mode'. Use: encrypt|decrypt" >&2
                  return 1
                  ;;
          esac
      fi

      case "$mode" in
          encrypt)
              local output
              output="$(_enc_output_path_encrypt "$file")"
              info "Archivo: ${file} ($(_enc_human_size "$file"))"
              info "Salida:  ${output}"
              info "Cifrando con AES-256-CBC + PBKDF2..."

              if _enc_do_encrypt "$file" "$output" "$password"; then
                  success "Archivo cifrado: ${output} ($(_enc_human_size "$output"))"
              else
                  error "encrypt_file: el cifrado falló" >&2
                  rm -f "$output"
                  return 1
              fi
              ;;
          decrypt)
              local output
              output="$(_enc_output_path_decrypt "$file")"
              info "Archivo: ${file} ($(_enc_human_size "$file"))"
              info "Salida:  ${output}"
              info "Descifrando..."

              if _enc_do_decrypt "$file" "$output" "$password"; then
                  success "Archivo descifrado: ${output} ($(_enc_human_size "$output"))"
              else
                  error "encrypt_file: el descifrado falló -- ¿contraseña incorrecta?" >&2
                  rm -f "$output"
                  return 1
              fi
              ;;
      esac

      # Limpiar contraseña de memoria
      password=""
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      encrypt_file "$@"
  fi

params_schema:
  {"params":[{"name":"mode","desc":"operación a realizar: encrypt (cifrar) o decrypt (descifrar)"},{"name":"file","desc":"ruta al archivo a cifrar o descifrar"}],"output":"genera el archivo cifrado (input.enc) o descifrado (input sin .enc, o input.dec) e imprime progreso a stdout"}


id: enumerate_subdomains_bash_cybersecurity
name: enumerate_subdomains
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: enumerate_subdomains(domain: string, output_file: string) -> void
tags: ["bash","cybersecurity","dns","subdomain","enumeration","reconnaissance","osint"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/enumerate_subdomains.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 821b91405f045abbc66b5794b2147c65f8961b582604e3a4636ab3ffbf8e2a53
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/web/subdominios.sh
description:
  Enumera subdominios de un dominio objetivo usando un diccionario integrado de ~100 subdominios comunes (www, mail, api, dev, admin, vpn, etc.). Detecta tanto registros A (IP directa) como CNAME. Muestra progreso cada 20 subdominios y opcionalmente guarda los resultados en un archivo.

example:
  ```bash
  source bash/functions/cybersecurity/enumerate_subdomains.sh

  # Solo imprimir resultados
  enumerate_subdomains example.com

  # Guardar resultados en archivo
  enumerate_subdomains example.com /tmp/subdominios.txt
  ```

notes:
  Requiere `dig` (paquete dnsutils). El diccionario integrado cubre subdominios comunes en entornos corporativos y de desarrollo: www, api, dev, admin, vpn, git, jenkins, staging, prod, db, mail, smtp, ns1/ns2, grafana, kibana, docker, k8s, auth, sso, etc. (~100 entradas). La enumeración es puramente pasiva via DNS — no realiza ningún tipo de conexión al servidor web. Los subdominios con CNAME sin resolución A se marcan en amarillo.

code:
  #!/usr/bin/env bash
  # enumerate_subdomains
  # --------------------
  # Enumera subdominios de un dominio objetivo usando un diccionario integrado de
  # ~100 subdominios comunes. Detecta tanto registros A (IP directa) como CNAME.
  # Opcionalmente guarda el resultado en un archivo.
  #
  # USO (directo):
  #   enumerate_subdomains <dominio> [archivo_salida]
  #   enumerate_subdomains example.com
  #   enumerate_subdomains example.com /tmp/resultado.txt
  #
  # Depende de: dig (dnsutils)

  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

  # ─── Diccionario integrado ─────────────────────────────────────────────────────

  _SUBDOMAIN_WORDLIST=(
      www mail ftp api dev admin vpn ssh git gitlab github jenkins ci cd
      staging prod test demo beta alpha app web portal intranet extranet
      remote desktop files cdn static assets media img images upload
      db database mysql postgres redis mongo smtp pop imap webmail mx
      ns1 ns2 dns autodiscover autoconfig crm erp shop store payment
      backup old legacy v1 v2 v3 internal corp office support helpdesk
      wiki docs doc status monitor grafana kibana elastic search
      registry docker k8s kubernetes auth sso login oauth api2 mobile
  )

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _sub_is_valid_domain() {
      [[ -n "$1" && "$1" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
  }

  _sub_build_fqdn() {
      local sub="$1"
      local domain="$2"
      echo "${sub}.${domain}"
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _sub_resolve_a() {
      local fqdn="$1"
      dig +short A "$fqdn" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || true
  }

  _sub_resolve_cname() {
      local fqdn="$1"
      dig +short CNAME "$fqdn" 2>/dev/null | head -1 || true
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  enumerate_subdomains() {
      local domain="$1"
      local output_file="${2:-}"

      if [[ -z "$domain" ]]; then
          error "enumerate_subdomains: se requiere un dominio como argumento" >&2
          return 1
      fi

      if ! _sub_is_valid_domain "$domain"; then
          error "enumerate_subdomains: dominio no válido: '$domain'" >&2
          return 1
      fi

      if ! command -v dig &>/dev/null; then
          error "enumerate_subdomains: 'dig' no está instalado (sudo apt install dnsutils)" >&2
          return 1
      fi

      local total="${#_SUBDOMAIN_WORDLIST[@]}"
      local found=0
      local checked=0

      info "Probando ${total} subdominios en ${domain}..."
      echo ""

      if [[ -n "$output_file" ]]; then
          echo "# Subdominios encontrados en ${domain} -- $(date)" > "$output_file"
      fi

      for sub in "${_SUBDOMAIN_WORDLIST[@]}"; do
          local fqdn
          fqdn="$(_sub_build_fqdn "$sub" "$domain")"
          checked=$((checked + 1))

          local ip
          ip="$(_sub_resolve_a "$fqdn")"

          if [[ -n "$ip" ]]; then
              echo -e "  ${GREEN}[ok]${NC} ${fqdn} -> ${CYAN}${ip}${NC}"
              [[ -n "$output_file" ]] && echo "${fqdn} -> ${ip}" >> "$output_file"
              found=$((found + 1))
          else
              local cname
              cname="$(_sub_resolve_cname "$fqdn")"
              if [[ -n "$cname" ]]; then
                  echo -e "  ${YELLOW}[cn]${NC} ${fqdn} -> CNAME: ${cname}"
                  [[ -n "$output_file" ]] && echo "${fqdn} -> CNAME: ${cname}" >> "$output_file"
                  found=$((found + 1))
              fi
          fi

          # Progreso cada 20 subdominios
          if (( checked % 20 == 0 )); then
              echo -e "  ${DIM_GRAY}[${checked}/${total} probados, ${found} encontrados]${NC}"
          fi
      done

      echo ""
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"

      if [[ $found -eq 0 ]]; then
          info "No se encontraron subdominios en el diccionario"
      else
          success "Total encontrados: ${found} de ${total} probados"
          [[ -n "$output_file" ]] && info "Resultado guardado en: ${output_file}"
      fi

      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      enumerate_subdomains "$@"
  fi

params_schema:
  {"params":[{"name":"domain","desc":"dominio objetivo a enumerar, ej: example.com"},{"name":"output_file","desc":"ruta al archivo donde guardar los resultados (opcional; si se omite, solo imprime a stdout)"}],"output":"imprime subdominios encontrados con su IP o CNAME, progreso cada 20 entradas y resumen final con total encontrados"}


id: generate_password_bash_cybersecurity
name: generate_password
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: generate_password(mode: string, length: int, count: int) -> void
tags: ["bash","cybersecurity","password","generator","entropy","security","urandom"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/generate_password.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: eef3e8801b43c05492568f971c4c302aa4423daa9c52fb0c7ae59840bfa8728d
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/utilidades/generar_password.sh
description:
  Genera contraseñas seguras en cuatro modos: full (alfanumérico + símbolos, excluye caracteres ambiguos), alpha (solo alfanumérico), passphrase (palabras aleatorias unidas con guión) y pin (numérico). Calcula y muestra la entropía en bits para cada modo.

example:
  ```bash
  source bash/functions/cybersecurity/generate_password.sh

  # Contraseña completa de 20 caracteres
  generate_password full 20

  # 5 contraseñas alfanuméricas de 16 caracteres
  generate_password alpha 16 5

  # Passphrase de 6 palabras
  generate_password passphrase 6

  # PIN de 8 dígitos
  generate_password pin 8
  ```

notes:
  Usa `/dev/urandom` como fuente de aleatoriedad criptográficamente segura. El modo `full` excluye caracteres ambiguos (0, O, l, I, 1) para mejorar legibilidad. El modo `passphrase` requiere un diccionario del sistema (`/usr/share/dict/words` o similar). La entropía se calcula como log2(charset^length) en bits. Las contraseñas nunca se escriben a disco.

code:
  #!/usr/bin/env bash
  # generate_password
  # -----------------
  # Genera contraseñas seguras en varios modos: completo (alfanumérico + símbolos),
  # solo alfanumérico, passphrase de palabras o PIN numérico.
  # Calcula la entropía en bits para cada contraseña generada.
  #
  # USO (directo):
  #   generate_password [full|alpha|passphrase|pin] [longitud] [cantidad]
  #
  # Depende de: /dev/urandom, python3 (para entropía), shuf (para passphrases)

  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 ───────────────────────────────────────────────────────────────

  _GENPW_DEFAULT_LENGTH=16
  _GENPW_DEFAULT_COUNT=1
  _GENPW_WORDLIST_PATHS=("/usr/share/dict/words" "/usr/dict/words" "/usr/share/dict/american-english")

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _genpw_find_wordlist() {
      for path in "${_GENPW_WORDLIST_PATHS[@]}"; do
          [[ -f "$path" ]] && echo "$path" && return
      done
      echo ""
  }

  _genpw_calc_entropy() {
      local charset_size="$1"
      local length="$2"
      python3 -c "import math; print(f'{math.log2(${charset_size}**${length}):.1f}')" 2>/dev/null || echo "?"
  }

  # ─── Funciones de generación ──────────────────────────────────────────────────

  _genpw_gen_full() {
      local length="$1"
      # Alfanumérico + símbolos (excluye ambiguos: 0OlI1)
      tr -dc 'A-HJ-NP-Za-km-z2-9!@#$%^&*()_+-=[]{}|;:,.<>?' \
          < /dev/urandom | head -c "$length"
      echo
  }

  _genpw_gen_alpha() {
      local length="$1"
      tr -dc 'A-HJ-NP-Za-km-z2-9' \
          < /dev/urandom | head -c "$length"
      echo
  }

  _genpw_gen_passphrase() {
      local words="$1"
      local wordlist
      wordlist="$(_genpw_find_wordlist)"

      if [[ -z "$wordlist" ]]; then
          error "generate_password: no se encontró diccionario (sudo apt install wamerican)" >&2
          return 1
      fi

      local phrase=""
      for ((i=0; i<words; i++)); do
          local word
          word="$(shuf -n1 "$wordlist" | tr -dc 'a-z' | head -c 20)"
          [[ ${#word} -lt 3 ]] && { i=$((i-1)); continue; }
          phrase="${phrase}${word}-"
      done
      echo "${phrase%-}"
  }

  _genpw_gen_pin() {
      local length="$1"
      tr -dc '0-9' < /dev/urandom | head -c "$length"
      echo
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  generate_password() {
      local mode="${1:-full}"
      local length="${2:-$_GENPW_DEFAULT_LENGTH}"
      local count="${3:-$_GENPW_DEFAULT_COUNT}"

      # Validar que length y count son numéricos
      if ! [[ "$length" =~ ^[0-9]+$ ]] || ! [[ "$count" =~ ^[0-9]+$ ]]; then
          error "generate_password: longitud y cantidad deben ser números enteros positivos" >&2
          return 1
      fi

      local charset_size entropy

      case "$mode" in
          full)
              charset_size=78
              entropy="$(_genpw_calc_entropy $charset_size "$length")"
              info "Contraseñas alfanuméricas + símbolos (longitud: ${length}, entropía: ~${entropy} bits)"
              echo ""
              for ((i=1; i<=count; i++)); do
                  _genpw_gen_full "$length"
              done
              ;;
          alpha)
              charset_size=56
              entropy="$(_genpw_calc_entropy $charset_size "$length")"
              info "Contraseñas alfanuméricas (longitud: ${length}, entropía: ~${entropy} bits)"
              echo ""
              for ((i=1; i<=count; i++)); do
                  _genpw_gen_alpha "$length"
              done
              ;;
          passphrase)
              info "Passphrases (${length} palabras)"
              echo ""
              for ((i=1; i<=count; i++)); do
                  _genpw_gen_passphrase "$length" || return 1
              done
              ;;
          pin)
              charset_size=10
              entropy="$(_genpw_calc_entropy $charset_size "$length")"
              info "PINs numéricos (longitud: ${length}, entropía: ~${entropy} bits)"
              echo ""
              for ((i=1; i<=count; i++)); do
                  _genpw_gen_pin "$length"
              done
              ;;
          *)
              error "generate_password: modo no válido '$mode'. Use: full|alpha|passphrase|pin" >&2
              return 1
              ;;
      esac
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      generate_password "$@"
  fi

params_schema:
  {"params":[{"name":"mode","desc":"modo de generación: full (alfanumérico+símbolos, por defecto), alpha (solo letras y números), passphrase (palabras), pin (numérico)"},{"name":"length","desc":"longitud en caracteres para full/alpha/pin, o número de palabras para passphrase (por defecto: 16)"},{"name":"count","desc":"número de contraseñas a generar (por defecto: 1)"}],"output":"imprime las contraseñas generadas a stdout (una por línea) con información de entropía"}


id: geolocate_ip_bash_cybersecurity
name: geolocate_ip
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: geolocate_ip(target: string) -> void
tags: ["bash","cybersecurity","network","geoip","ip","osint","reconnaissance"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/geolocate_ip.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 2a181b18b0ef94bcdd0c45763efbf88152199f6dc952368806480bc654c83361
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/redes/geoip.sh
description:
  Geolocaliza una dirección IP o dominio usando la API pública de ip-api.com. Muestra país, región, ciudad, coordenadas, ISP, ASN y detecta VPN, Proxy o infraestructura de hosting.

example:
  ```bash
  source bash/functions/cybersecurity/geolocate_ip.sh

  # Geolocalizar una IP
  geolocate_ip 8.8.8.8

  # Geolocalizar un dominio (resuelve a IP primero)
  geolocate_ip example.com
  ```

notes:
  Requiere `curl`. Si se pasa un dominio en lugar de una IP, se resuelve a IP usando `dig` antes de consultar la API. La API de ip-api.com es gratuita para uso no comercial con límite de 45 req/min. Los campos `proxy=true` y `hosting=true` indican posible uso de VPN, proxy Tor o datacenter.

code:
  #!/usr/bin/env bash
  # geolocate_ip
  # ------------
  # Geolocaliza una IP o dominio usando la API pública de ip-api.com.
  # Muestra país, ciudad, ISP, ASN y detecta VPN/Proxy/Hosting.
  #
  # USO (directo):
  #   geolocate_ip <ip_o_dominio>
  #
  # Depende de: curl, dig (para resolver dominios)

  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 ───────────────────────────────────────────────────────────────

  _GEOIP_API="http://ip-api.com/json"
  _GEOIP_FIELDS="status,message,country,countryCode,regionName,city,zip,lat,lon,isp,org,as,proxy,hosting,query"

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _geo_is_ip() {
      [[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]
  }

  _geo_build_api_url() {
      local target="$1"
      echo "${_GEOIP_API}/${target}?fields=${_GEOIP_FIELDS}"
  }

  _geo_extract_field() {
      local json="$1"
      local key="$2"
      echo "$json" | grep -o "\"${key}\":[^,}]*" | cut -d: -f2- | tr -d '"' | xargs
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _geo_resolve_domain() {
      local domain="$1"
      dig +short A "$domain" 2>/dev/null | head -1 || true
  }

  _geo_fetch() {
      local target="$1"
      local url
      url="$(_geo_build_api_url "$target")"
      curl -s --max-time 10 "$url" 2>/dev/null
  }

  _geo_display_result() {
      local json="$1"

      local status
      status="$(_geo_extract_field "$json" "status")"
      if [[ "$status" != "success" ]]; then
          local msg
          msg="$(_geo_extract_field "$json" "message")"
          error "La API devolvió error: ${msg:-respuesta inesperada}" >&2
          return 1
      fi

      local ip_queried country country_code region city zip lat lon isp org asn proxy hosting
      ip_queried="$(_geo_extract_field "$json" "query")"
      country="$(_geo_extract_field "$json" "country")"
      country_code="$(_geo_extract_field "$json" "countryCode")"
      region="$(_geo_extract_field "$json" "regionName")"
      city="$(_geo_extract_field "$json" "city")"
      zip="$(_geo_extract_field "$json" "zip")"
      lat="$(_geo_extract_field "$json" "lat")"
      lon="$(_geo_extract_field "$json" "lon")"
      isp="$(_geo_extract_field "$json" "isp")"
      org="$(_geo_extract_field "$json" "org")"
      asn="$(_geo_extract_field "$json" "as")"
      proxy="$(_geo_extract_field "$json" "proxy")"
      hosting="$(_geo_extract_field "$json" "hosting")"

      echo ""
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
      echo -e "  ${CYAN}IP consultada:${NC}  ${ip_queried}"
      echo -e "  ${CYAN}País:${NC}           ${country} (${country_code})"
      echo -e "  ${CYAN}Región:${NC}         ${region}"
      echo -e "  ${CYAN}Ciudad:${NC}         ${city} ${zip}"
      echo -e "  ${CYAN}Coordenadas:${NC}    ${lat}, ${lon}"
      echo -e "  ${CYAN}ISP:${NC}            ${isp}"
      echo -e "  ${CYAN}Organización:${NC}   ${org}"
      echo -e "  ${CYAN}ASN:${NC}            ${asn}"
      echo ""

      if [[ "$proxy" == "true" ]]; then
          echo -e "  ${RED}[!] VPN / Proxy detectado${NC}"
      fi
      if [[ "$hosting" == "true" ]]; then
          echo -e "  ${YELLOW}[i] Hosting / datacenter detectado${NC}"
      fi
      if [[ "$proxy" != "true" && "$hosting" != "true" ]]; then
          echo -e "  ${GREEN}[ok] Sin indicios de VPN, Proxy o Tor${NC}"
      fi

      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  geolocate_ip() {
      local target="$1"

      if [[ -z "$target" ]]; then
          error "geolocate_ip: se requiere una IP o dominio como argumento" >&2
          return 1
      fi

      if ! command -v curl &>/dev/null; then
          error "geolocate_ip: 'curl' no está instalado (sudo apt install curl)" >&2
          return 1
      fi

      local query_target="$target"

      if ! _geo_is_ip "$target"; then
          info "Resolviendo dominio a IP..."
          local resolved
          resolved="$(_geo_resolve_domain "$target")"
          if [[ -z "$resolved" ]]; then
              error "geolocate_ip: no se pudo resolver '$target'" >&2
              return 1
          fi
          info "Resuelto: ${target} -> ${resolved}"
          query_target="$resolved"
      fi

      info "Consultando geolocalización de ${query_target}..."
      local json
      json="$(_geo_fetch "$query_target")"

      if [[ -z "$json" ]]; then
          error "geolocate_ip: no se obtuvo respuesta de la API" >&2
          return 1
      fi

      _geo_display_result "$json"
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      geolocate_ip "$@"
  fi

params_schema:
  {"params":[{"name":"target","desc":"dirección IP (IPv4) o nombre de dominio a geolocalizar"}],"output":"imprime información de geolocalización a stdout: país, ciudad, ISP, ASN, coordenadas y flags de VPN/Proxy/Hosting"}


id: inspect_ssl_cert_bash_cybersecurity
name: inspect_ssl_cert
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: inspect_ssl_cert(host: string) -> void
tags: ["bash","cybersecurity","ssl","tls","certificate","web","openssl","security"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/inspect_ssl_cert.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: a5507dbab5e0ef47b4539050df093b1e7cc4255256155fe0f48fdd3d25846f37
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/web/ssl_cert_info.sh
description:
  Inspecciona el certificado SSL/TLS de un host: muestra sujeto, emisor, fechas de validez, días hasta expiración, SANs (Subject Alternative Names), cadena de confianza completa y detecta soporte de versiones inseguras TLS 1.0/1.1.

example:
  ```bash
  source bash/functions/cybersecurity/inspect_ssl_cert.sh

  # Puerto 443 por defecto
  inspect_ssl_cert example.com

  # Puerto personalizado
  inspect_ssl_cert example.com:8443

  # API interna
  inspect_ssl_cert api.internal.example.com:4443
  ```

notes:
  Requiere `openssl` y `timeout`. Usa `openssl s_client` con SNI (`-servername`) para soportar virtual hosting. La alerta de expiración se activa a 30 días o menos. La detección de TLS 1.0/1.1 usa flags `-tls1` y `-tls1_1` de openssl s_client — si el servidor acepta la conexión y negocia un cipher, el protocolo inseguro está habilitado. Cada conexión tiene timeout de 10 segundos para evitar cuelgues en hosts sin respuesta.

code:
  #!/usr/bin/env bash
  # inspect_ssl_cert
  # ----------------
  # Inspecciona el certificado SSL/TLS de un host: sujeto, emisor, fechas de validez,
  # SANs, cadena de confianza y versiones de TLS aceptadas (detecta TLS 1.0/1.1 inseguros).
  #
  # USO (directo):
  #   inspect_ssl_cert <host[:puerto]>
  #   inspect_ssl_cert example.com
  #   inspect_ssl_cert example.com:8443
  #
  # Depende de: openssl, timeout

  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 ───────────────────────────────────────────────────────────────

  _SSL_WARN_DAYS=30

  # ─── Funciones puras ──────────────────────────────────────────────────────────

  _ssl_parse_host_port() {
      local input="$1"
      if [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
          echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
      else
          echo "${input} 443"
      fi
  }

  _ssl_days_until_expiry() {
      local expiry_str="$1"
      local expiry_epoch
      expiry_epoch="$(date -d "$expiry_str" +%s 2>/dev/null || echo 0)"
      local now_epoch
      now_epoch="$(date +%s)"
      echo $(( (expiry_epoch - now_epoch) / 86400 ))
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _ssl_fetch_subject() {
      local host="$1"
      local port="$2"
      echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
          | openssl x509 -noout -subject -issuer 2>/dev/null
  }

  _ssl_fetch_dates() {
      local host="$1"
      local port="$2"
      echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
          | openssl x509 -noout -dates 2>/dev/null
  }

  _ssl_fetch_san() {
      local host="$1"
      local port="$2"
      echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
          | openssl x509 -noout -ext subjectAltName 2>/dev/null \
          | grep -oE 'DNS:[^,]+' | sed 's/DNS://g' | tr '\n' ' '
  }

  _ssl_fetch_chain() {
      local host="$1"
      local port="$2"
      echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" \
          -showcerts 2>/dev/null \
          | grep -E "^(subject|issuer)=" | sed 's/^/    /'
  }

  _ssl_check_tls_version() {
      local host="$1"
      local port="$2"
      local proto="$3"
      local label="$4"
      if echo | timeout 5 openssl s_client -connect "${host}:${port}" \
          -servername "$host" "${proto}" 2>/dev/null | grep -q "Cipher"; then
          echo -e "  ${RED}[x]${NC} ${label} -- soportado (inseguro)"
      else
          echo -e "  ${GREEN}[ok]${NC} ${label} no soportado"
      fi
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  inspect_ssl_cert() {
      local input="$1"

      if [[ -z "$input" ]]; then
          error "inspect_ssl_cert: se requiere un host como argumento (ej: example.com o example.com:8443)" >&2
          return 1
      fi

      if ! command -v openssl &>/dev/null; then
          error "inspect_ssl_cert: 'openssl' no está instalado (sudo apt install openssl)" >&2
          return 1
      fi

      local host port
      read -r host port <<< "$(_ssl_parse_host_port "$input")"

      info "Conectando a ${host}:${port}..."
      echo ""

      local subj_issuer
      subj_issuer="$(_ssl_fetch_subject "$host" "$port")"
      if [[ -z "$subj_issuer" ]]; then
          error "inspect_ssl_cert: no se pudo obtener el certificado. ¿El host está disponible?" >&2
          return 1
      fi

      local subject issuer
      subject="$(echo "$subj_issuer" | grep ^subject | cut -d= -f2- | xargs)"
      issuer="$(echo "$subj_issuer"  | grep ^issuer  | cut -d= -f2- | xargs)"

      local dates
      dates="$(_ssl_fetch_dates "$host" "$port")"
      local not_before not_after
      not_before="$(echo "$dates" | grep notBefore | cut -d= -f2)"
      not_after="$(echo "$dates"  | grep notAfter  | cut -d= -f2)"

      local days_left
      days_left="$(_ssl_days_until_expiry "$not_after")"

      local sans
      sans="$(_ssl_fetch_san "$host" "$port")"

      echo -e "${PURPLE}════════ Certificado SSL/TLS ════════════════════${NC}"
      echo -e "  ${CYAN}Sujeto:${NC}        ${subject}"
      echo -e "  ${CYAN}Emisor:${NC}        ${issuer}"
      echo -e "  ${CYAN}Válido desde:${NC}  ${not_before}"
      echo -e "  ${CYAN}Válido hasta:${NC}  ${not_after}"
      echo ""

      if [[ $days_left -le 0 ]]; then
          echo -e "  ${RED}[x] CERTIFICADO EXPIRADO${NC}"
      elif [[ $days_left -le $_SSL_WARN_DAYS ]]; then
          echo -e "  ${YELLOW}[!] Expira en ${days_left} días -- renovar pronto${NC}"
      else
          echo -e "  ${GREEN}[ok] Válido -- expira en ${days_left} días${NC}"
      fi

      echo ""
      echo -e "  ${CYAN}SANs:${NC}"
      echo "$sans" | tr ' ' '\n' | grep -v '^$' | while IFS= read -r san; do
          echo -e "    ${GREEN}*${NC} ${san}"
      done

      echo ""
      echo -e "${PURPLE}════════ Cadena de confianza ════════════════════${NC}"
      _ssl_fetch_chain "$host" "$port"

      echo ""
      echo -e "${PURPLE}════════ Versiones TLS aceptadas ════════════════${NC}"
      _ssl_check_tls_version "$host" "$port" "-tls1"   "TLS 1.0"
      _ssl_check_tls_version "$host" "$port" "-tls1_1" "TLS 1.1"
      echo -e "  ${GREEN}[ok]${NC} TLS 1.2 / 1.3 (estándar)"
      echo -e "${PURPLE}═════════════════════════════════════════════════${NC}"
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      inspect_ssl_cert "$@"
  fi

params_schema:
  {"params":[{"name":"host","desc":"host a inspeccionar, acepta formato host o host:puerto (por defecto puerto 443), ej: example.com o example.com:8443"}],"output":"imprime detalles del certificado SSL/TLS, días hasta expiración con nivel de alerta, SANs, cadena de confianza y resultado de checks de versiones TLS"}


id: list_active_connections_bash_cybersecurity
name: list_active_connections
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: list_active_connections(mode: string) -> void
tags: ["bash","cybersecurity","network","connections","monitoring","ss","ports"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/list_active_connections.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 5f5d050fdaf6abb6597b8fc83a04bdb38c6cc54d886b5f806eb8f484cce43778
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/redes/conexiones_activas.sh
description:
  Muestra conexiones de red activas del sistema usando ss: puertos en escucha, conexiones establecidas y detección de conexiones hacia IPs externas (excluye RFC1918, loopback y link-local).

example:
  ```bash
  source bash/functions/cybersecurity/list_active_connections.sh

  # Todas las conexiones
  list_active_connections

  # Solo puertos en escucha
  list_active_connections listening

  # Solo conexiones hacia internet
  list_active_connections external
  ```

notes:
  Requiere `ss` del paquete iproute2 (disponible por defecto en la mayoría de distribuciones modernas). La detección de IPs externas excluye: 127.x, ::1, 0.0.0.0, rangos RFC1918 (10.x, 172.16-31.x, 192.168.x) y link-local (fe80:). Usa `ss -tnp` para mostrar el proceso asociado a cada conexión (puede requerir sudo para ver procesos de otros usuarios).

code:
  #!/usr/bin/env bash
  # list_active_connections
  # -----------------------
  # Muestra conexiones de red activas del sistema: puertos en escucha,
  # conexiones establecidas y detección de IPs externas (no RFC1918).
  #
  # USO (directo):
  #   list_active_connections [listening|established|external|all]
  #
  # Depende de: ss (iproute2)

  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 ──────────────────────────────────────────────────────────

  _conn_is_external_ip() {
      local ip="$1"
      # Devuelve 0 (verdadero) si no es loopback, link-local ni RFC1918
      [[ ! "$ip" =~ ^(127\.|::1|0\.0\.0\.0|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|fe80:) ]]
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _conn_show_listening() {
      info "Puertos en escucha con proceso asociado..."
      echo ""
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
      ss -tlnp 2>/dev/null \
          | awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $7}' \
          | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
  }

  _conn_show_established() {
      info "Conexiones establecidas..."
      echo ""
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
      ss -tnp state established 2>/dev/null \
          | awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $6}' \
          | while IFS= read -r line; do echo -e "  ${DIM_GRAY}${line}${NC}"; done
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
  }

  _conn_show_external() {
      info "Conexiones hacia IPs externas..."
      echo ""
      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"

      local found=0
      while IFS= read -r line; do
          local peer
          peer="$(echo "$line" | awk '{print $5}' | cut -d: -f1)"
          if _conn_is_external_ip "$peer"; then
              echo -e "  ${YELLOW}*${NC} $line"
              found=$((found + 1))
          fi
      done < <(ss -tnp state established 2>/dev/null | tail -n +2)

      echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
      echo ""
      if [[ $found -eq 0 ]]; then
          success "No se detectaron conexiones hacia IPs externas"
      else
          info "Total conexiones externas: $found"
      fi
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  list_active_connections() {
      local mode="${1:-all}"

      if ! command -v ss &>/dev/null; then
          error "list_active_connections: 'ss' no está disponible (sudo apt install iproute2)" >&2
          return 1
      fi

      case "$mode" in
          listening)
              _conn_show_listening
              ;;
          established)
              _conn_show_established
              ;;
          external)
              _conn_show_external
              ;;
          all)
              _conn_show_listening
              echo ""
              _conn_show_established
              echo ""
              _conn_show_external
              ;;
          *)
              error "list_active_connections: modo no válido '$mode'. Use: listening|established|external|all" >&2
              return 1
              ;;
      esac
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      list_active_connections "$@"
  fi

params_schema:
  {"params":[{"name":"mode","desc":"modo de visualización: listening (puertos en escucha), established (conexiones activas), external (solo IPs externas) o all (todo, por defecto)"}],"output":"imprime tabla de conexiones de red a stdout con colores ANSI; las conexiones a IPs externas se resaltan en amarillo"}


id: verify_file_hash_bash_cybersecurity
name: verify_file_hash
kind: function
lang: bash
domain: cybersecurity
version: 1.0.0
purity: impure
signature: verify_file_hash(file: string, algorithm: string, expected_hash: string) -> void
tags: ["bash","cybersecurity","hash","integrity","checksum","md5","sha256","sha512"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/cybersecurity/verify_file_hash.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: f2139f531913b1163696636398bcae670175133ced8af836495c182441392657
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/ciberseguridad/utilidades/verificar_hash.sh
description:
  Calcula el hash criptográfico de un archivo con el algoritmo especificado (md5, sha1, sha256, sha512) y opcionalmente lo compara con un hash esperado para verificar integridad. Retorna exit code 1 si los hashes no coinciden.

example:
  ```bash
  source bash/functions/cybersecurity/verify_file_hash.sh

  # Solo calcular el hash
  verify_file_hash archivo.iso sha256

  # Verificar contra un hash conocido
  verify_file_hash archivo.iso sha256 "a1b2c3d4e5f6..."

  # MD5 (solo para compatibilidad, no recomendado para seguridad)
  verify_file_hash documento.pdf md5
  ```

notes:
  La comparación de hashes es case-insensitive (normaliza a minúsculas). SHA256 es el algoritmo recomendado para verificación de integridad. MD5 y SHA1 están deprecados para uso en seguridad pero se incluyen para compatibilidad con sumas publicadas en sistemas legacy. Retorna exit code 1 cuando los hashes no coinciden, lo que permite usar la función en scripts con `set -e`.

code:
  #!/usr/bin/env bash
  # verify_file_hash
  # ----------------
  # Calcula el hash de un archivo con el algoritmo indicado (md5, sha1, sha256, sha512)
  # y opcionalmente lo compara con un hash esperado.
  #
  # USO (directo):
  #   verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]
  #
  # Depende de: md5sum, sha1sum, sha256sum, sha512sum

  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 ──────────────────────────────────────────────────────────

  _hash_select_cmd() {
      local algo="$1"
      case "$algo" in
          md5)    echo "md5sum" ;;
          sha1)   echo "sha1sum" ;;
          sha256) echo "sha256sum" ;;
          sha512) echo "sha512sum" ;;
          *)      echo "" ;;
      esac
  }

  _hash_hashes_match() {
      local a="${1,,}"
      local b="${2,,}"
      [[ "$a" == "$b" ]]
  }

  # ─── Funciones de efecto ──────────────────────────────────────────────────────

  _hash_compute() {
      local cmd="$1"
      local file="$2"
      "$cmd" "$file" 2>/dev/null | awk '{print $1}'
  }

  # ─── Punto de entrada ─────────────────────────────────────────────────────────

  verify_file_hash() {
      local file="$1"
      local algorithm="$2"
      local expected_hash="${3:-}"

      if [[ -z "$file" || -z "$algorithm" ]]; then
          error "verify_file_hash: uso: verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]" >&2
          return 1
      fi

      if [[ ! -f "$file" ]]; then
          error "verify_file_hash: archivo no encontrado: $file" >&2
          return 1
      fi

      local cmd
      cmd="$(_hash_select_cmd "$algorithm")"

      if [[ -z "$cmd" ]]; then
          error "verify_file_hash: algoritmo no válido '$algorithm'. Use: md5|sha1|sha256|sha512" >&2
          return 1
      fi

      if ! command -v "$cmd" &>/dev/null; then
          error "verify_file_hash: '$cmd' no está disponible" >&2
          return 1
      fi

      info "Calculando ${algorithm^^} de: $(basename "$file")"
      local hash
      hash="$(_hash_compute "$cmd" "$file")"

      echo ""
      echo -e "  ${CYAN}Archivo:${NC} ${file}"
      echo -e "  ${CYAN}${algorithm^^}:${NC}    ${hash}"
      echo ""

      if [[ -n "$expected_hash" ]]; then
          if _hash_hashes_match "$hash" "$expected_hash"; then
              echo -e "  ${GREEN}[COINCIDE]${NC} La integridad del archivo es correcta"
          else
              echo -e "  ${RED}[NO COINCIDE]${NC} El archivo puede estar corrupto o modificado"
              echo ""
              echo -e "  ${CYAN}Calculado:${NC} ${hash}"
              echo -e "  ${CYAN}Esperado: ${NC} ${expected_hash}"
              return 1
          fi
      fi
  }

  # Ejecutar si se llama directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      verify_file_hash "$@"
  fi

params_schema:
  {"params":[{"name":"file","desc":"ruta al archivo del que calcular el hash"},{"name":"algorithm","desc":"algoritmo de hash a usar: md5, sha1, sha256 (recomendado) o sha512"},{"name":"expected_hash","desc":"hash esperado en hexadecimal para verificar integridad (opcional; si se omite, solo calcula e imprime)"}],"output":"imprime el hash calculado; si se proporcionó expected_hash, imprime COINCIDE o NO COINCIDE y retorna exit code 1 si no coinciden"}


id: analyze_disk_space_bash_infra
name: analyze_disk_space
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: analyze_disk_space([target_dir: string], [mode: string]) -> void
tags: ["bash","disk","space","analysis","filesystem"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/analyze_disk_space.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 00768368fbd681ae0f0f74fd414dc245c75fe58e5b0443e5afd383b153f92386
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/gestion_linux/espacio_disponible.sh
description:
  Analiza el uso de espacio en disco. Modos: partitions (df con filtros), top-dirs (du top 10), top-files (find top 20), inodes (df -i), all (todos). Emite advertencias si el uso supera el 90%.

example:
  ```bash
  source bash/functions/infra/analyze_disk_space.sh

  # Análisis completo del directorio raíz
  analyze_disk_space

  # Solo particiones
  analyze_disk_space / partitions

  # Top directorios en home
  analyze_disk_space "$HOME" top-dirs

  # Solo inodos
  analyze_disk_space / inodes
  ```

notes:
  Excluye tmpfs, devtmpfs y loop de los resultados de df. No realiza ninguna limpieza destructiva. El modo top-files puede tardar en sistemas con muchos archivos.

code:
  #!/usr/bin/env bash
  # analyze_disk_space
  # ------------------
  # Analiza el uso de espacio en disco: particiones, directorios más grandes,
  # archivos más grandes e inodos.
  #
  # USO:
  #   source analyze_disk_space.sh
  #   analyze_disk_space [target_dir] [mode]
  #
  # ARGUMENTOS:
  #   target_dir   Directorio a analizar (default: /)
  #   mode         Modo de análisis: partitions|top-dirs|top-files|inodes|all (default: all)

  analyze_disk_space() {
      local target_dir="${1:-/}"
      local mode="${2:-all}"

      _ads_partitions() {
          echo "=== Espacio en sistemas de archivos ==="
          df -h --output=source,fstype,size,used,avail,pcent,target 2>/dev/null \
              | grep -v "tmpfs\|devtmpfs\|loop" | column -t
          echo ""

          local high_usage
          high_usage="$(df -h | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
          if [[ -n "$high_usage" ]]; then
              echo "ADVERTENCIA: Discos con uso >90%:"
              echo "$high_usage"
              echo ""
          fi
      }

      _ads_top_dirs() {
          local dir="${1:-.}"
          echo "=== Top 10 carpetas más grandes en: $(realpath "$dir") ==="
          du -h --max-depth=1 "$dir" 2>/dev/null | sort -rh | head -11 \
              | awk '{printf "%-10s %s\n", $1, $2}'
          echo ""
      }

      _ads_top_files() {
          local dir="${1:-.}"
          echo "=== Top 20 archivos más grandes en: $(realpath "$dir") ==="
          find "$dir" -type f -exec du -h {} + 2>/dev/null | sort -rh | head -20 \
              | awk '{printf "%-10s %s\n", $1, $2}'
          echo ""
      }

      _ads_inodes() {
          echo "=== Inodos disponibles ==="
          df -i 2>/dev/null | grep -v "tmpfs\|devtmpfs\|loop" | column -t
          echo ""

          local high_inodes
          high_inodes="$(df -i | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
          if [[ -n "$high_inodes" ]]; then
              echo "ADVERTENCIA: Sistemas de archivos con >90% de inodos usados:"
              echo "$high_inodes"
              echo ""
          fi
      }

      case "$mode" in
          partitions)
              _ads_partitions
              ;;
          top-dirs)
              _ads_top_dirs "$target_dir"
              ;;
          top-files)
              _ads_top_files "$target_dir"
              ;;
          inodes)
              _ads_inodes
              ;;
          all)
              _ads_partitions
              _ads_top_dirs "$target_dir"
              _ads_top_files "$target_dir"
              _ads_inodes
              ;;
          *)
              echo "analyze_disk_space: modo desconocido '${mode}'. Usa: partitions|top-dirs|top-files|inodes|all" >&2
              return 1
              ;;
      esac
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      analyze_disk_space "$@"
  fi

params_schema:
  {"params":[{"name":"target_dir","desc":"directorio a analizar para top-dirs y top-files (default: /)"},{"name":"mode","desc":"qué analizar: partitions|top-dirs|top-files|inodes|all (default: all)"}],"output":"informe de uso de disco a stdout; advertencias a stdout si uso \u003e90%; exit code 1 si modo desconocido"}


id: assert_docker_container_running_bash_infra
name: assert_docker_container_running
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: assert_docker_container_running(container_name: string) -> void
tags: ["assert","docker","container","running","validation","infra","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/assert_docker_container_running.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 145d610fbb3cbe56b2beee02a4de3ff4d79a866082f84bf94d03c6b571681d97
description:
  Verifica que un contenedor Docker está corriendo. Sale con exit code 1 si no está activo, con mensaje a stderr.

example:
  ```bash
  source functions/infra/assert_docker_container_running.sh

  assert_docker_container_running metabase
  echo "Contenedor activo, continuando..."
  ```

notes:
  Usa `docker ps --format '{{.Names}}'` con grep anclado (`^name$`) para evitar matches parciales (ej: "metabase" no matchea "metabase-test").

  Output limpio: void en éxito. El mensaje de error en stderr no incluye lista de contenedores activos — eso es responsabilidad del pipeline/caller.

  Requiere que `docker` esté en PATH. Combinar con `assert_command_exists` antes de llamar.

code:
  # assert_docker_container_running
  # --------------------------------
  # Verifica que un contenedor Docker está corriendo.
  # No produce output a stdout en caso de éxito.
  # Sale con exit code 1 si el contenedor no está corriendo,
  # con mensaje descriptivo a stderr.
  #
  # USO (sourced):
  #   source assert_docker_container_running.sh
  #   assert_docker_container_running metabase

  assert_docker_container_running() {
      local container_name="$1"

      if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
          echo "assert_docker_container_running: el contenedor '$container_name' no está corriendo" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"container_name","desc":"nombre del contenedor Docker a verificar"}],"output":"sin salida; exit code 0 si existe y está corriendo, 1 si no"}


id: build_cpp_linux_bash_infra
name: build_cpp_linux
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: build_cpp_linux(target?: string) -> void
description: Compila las funciones y apps C++ del registry para Linux nativo usando cmake
tags: ["cpp","build","cmake","linux","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/build_cpp_linux.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: 7aad296717375fac47ac533cbf3d258df7104d27a04da06322b871d5adaf9c82
documentation:
  # build_cpp_linux

  Configura y compila el proyecto C++ (ImGui/ImPlot) para Linux nativo.

  Usa cmake con compilacion paralela (`-j$(nproc)`). Si no se ha configurado antes, ejecuta `cmake -B` automaticamente.

  ```bash

  fn run build_cpp_linux              # Compilar todo

  fn run build_cpp_linux chart_demo   # Compilar solo chart_demo

  ```

code:
  #!/usr/bin/env bash
  set -euo pipefail

  REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
  CPP_ROOT="$REGISTRY_ROOT/cpp"
  BUILD_DIR="$CPP_ROOT/build/linux"
  TARGET="${1:-}"

  # Configure if needed
  if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
      echo "[build_cpp_linux] Configuring cmake..."
      cmake -B "$BUILD_DIR" -S "$CPP_ROOT"
  fi

  # Build
  if [ -n "$TARGET" ]; then
      echo "[build_cpp_linux] Building target: $TARGET"
      cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
  else
      echo "[build_cpp_linux] Building all targets..."
      cmake --build "$BUILD_DIR" -- -j"$(nproc)"
  fi

  echo "[build_cpp_linux] Done. Binaries in $BUILD_DIR"

params_schema:
  {"params":[{"name":"target","desc":"Nombre del target cmake a compilar (opcional, sin argumento compila todo)"}],"output":"Compila los binarios en cpp/build/linux/"}


id: build_cpp_windows_bash_infra
name: build_cpp_windows
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: build_cpp_windows(target?: string) -> void
description: Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64
tags: ["cpp","build","cmake","windows","cross-compile","mingw","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/build_cpp_windows.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: ee946317d4f02273b0c7e28b686e422c24b8e197d91895a07e6407a47f7cb95d
documentation:
  # build_cpp_windows

  Cross-compila el proyecto C++ para Windows desde Linux usando el toolchain mingw-w64.

  Los .exe resultantes incluyen runtime linkado estaticamente (self-contained).

  ```bash

  fn run build_cpp_windows              # Compilar todo

  fn run build_cpp_windows chart_demo   # Compilar solo chart_demo

  ```

  Requiere `mingw-w64`: `sudo apt install mingw-w64`

code:
  #!/usr/bin/env bash
  set -euo pipefail

  REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
  CPP_ROOT="$REGISTRY_ROOT/cpp"
  BUILD_DIR="$CPP_ROOT/build/windows"
  TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake"
  TARGET="${1:-}"

  # Check mingw is available
  if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then
      echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64"
      exit 1
  fi

  # Configure if needed
  if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
      echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..."
      cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN"
  fi

  # Build
  if [ -n "$TARGET" ]; then
      echo "[build_cpp_windows] Cross-compiling target: $TARGET"
      cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
  else
      echo "[build_cpp_windows] Cross-compiling all targets..."
      cmake --build "$BUILD_DIR" -- -j"$(nproc)"
  fi

  echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR"
  if [ -n "$TARGET" ]; then
      file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true
  fi

params_schema:
  {"params":[{"name":"target","desc":"Nombre del target cmake a compilar (opcional, sin argumento compila todo)"}],"output":"Produce binarios .exe de Windows en cpp/build/windows/"}


id: detect_wsl_bash_infra
name: detect_wsl
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: detect_wsl([--check]) -> void
tags: ["bash","wsl","windows","detect","integration"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/detect_wsl.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 03fb4e6885b8b3e1909b9fcf16ed10821d1dc31e91025506de0672e173c394cf
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/gestion_linux/wsl_host.sh
description:
  Detecta si el sistema es WSL (Windows Subsystem for Linux). Con --check retorna solo exit code (0=WSL, 1=no WSL) sin output. Sin argumentos imprime versión WSL, usuario Windows, distribución, hostname, unidades montadas y ruta Windows del directorio actual.

example:
  ```bash
  source bash/functions/infra/detect_wsl.sh

  # Verificar si es WSL en scripts (sin output)
  if detect_wsl --check; then
      echo "Estamos en WSL"
  fi

  # Mostrar información completa del entorno WSL
  detect_wsl
  ```

notes:
  Usa tres métodos de detección en orden: /proc/version, /proc/sys/kernel/osrelease, y la presencia de /mnt/c + WSLInterop. No incluye las acciones interactivas del script original (abrir PowerShell, CMD, Explorer, VS Code).

code:
  #!/usr/bin/env bash
  # detect_wsl
  # ----------
  # Detecta si el sistema actual es WSL (Windows Subsystem for Linux).
  # Con --check solo retorna exit code (0=WSL, 1=no WSL) sin output.
  # Sin argumentos, imprime información completa del entorno WSL.
  #
  # USO:
  #   source detect_wsl.sh
  #   detect_wsl [--check]

  detect_wsl() {
      local check_only=false
      [[ "${1:-}" == "--check" ]] && check_only=true

      # Detección interna de WSL
      _is_wsl() {
          if [[ -f /proc/version ]] && grep -qi "microsoft\|wsl" /proc/version; then
              return 0
          fi
          if [[ -f /proc/sys/kernel/osrelease ]] && grep -qi "microsoft\|wsl" /proc/sys/kernel/osrelease; then
              return 0
          fi
          if [[ -d /mnt/c ]] && [[ -f /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
              return 0
          fi
          return 1
      }

      _get_wsl_version() {
          if [[ -f /proc/version ]]; then
              if grep -qi "WSL2" /proc/version; then
                  echo "WSL2"
              elif grep -qi "microsoft" /proc/version; then
                  echo "WSL1"
              else
                  echo "Unknown"
              fi
          else
              echo "Unknown"
          fi
      }

      _get_windows_username() {
          if [[ -n "${WSLENV:-}" ]]; then
              cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r\n' || echo "Unknown"
          else
              echo "Unknown"
          fi
      }

      # Modo --check: solo exit code
      if [[ "$check_only" == true ]]; then
          _is_wsl && return 0 || return 1
      fi

      # Modo informativo
      if ! _is_wsl; then
          echo "detect_wsl: este sistema NO es WSL" >&2
          return 1
      fi

      local wsl_version
      wsl_version="$(_get_wsl_version)"

      local win_user
      win_user="$(_get_windows_username)"

      local distro="Unknown"
      if [[ -f /etc/os-release ]]; then
          distro="$(. /etc/os-release && echo "${PRETTY_NAME:-${ID:-Unknown}}")"
      fi

      echo "=== Entorno WSL ==="
      echo "  Versión de WSL:    ${wsl_version}"
      echo "  Usuario Windows:   ${win_user}"
      echo "  Distribución:      ${distro}"
      echo "  Hostname:          $(hostname)"
      echo ""

      echo "=== Unidades de Windows montadas ==="
      ls /mnt/ 2>/dev/null | grep -E "^[a-z]$" | while IFS= read -r drive; do
          echo "  ${drive}: → /mnt/${drive}"
      done
      echo ""

      local current_win_path
      current_win_path="$(wslpath -w "$(pwd)" 2>/dev/null || echo "N/A")"
      echo "=== Directorio actual en Windows ==="
      echo "  ${current_win_path}"
      echo ""
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      detect_wsl "$@"
  fi

params_schema:
  {"params":[{"name":"--check","desc":"flag: solo detecta y retorna exit code sin producir output (0=WSL, 1=no WSL)"}],"output":"sin output con --check; informe del entorno WSL a stdout sin argumentos; exit code 1 si no es WSL"}


id: docker_compose_remote_deploy_bash_infra
name: docker_compose_remote_deploy
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
tags: ["docker","compose","deploy","ssh","remote","git","infra","cicd"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/docker_compose_remote_deploy.sh
created_at: 2026-04-12T18:29:33Z
updated_at: 2026-04-12T18:29:33Z
props: []
emits: []
variant: []
content_hash: 494783ceb3e5f7e91430ee0fcb6aa655ad88900833952fe257df2b6f235237cc
signature:
  docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json

description:
  Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion.

example:
  ```bash
  source bash/functions/infra/docker_compose_remote_deploy.sh

  # Deploy basico (solo docker-compose.yml, branch main)
  result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element")
  echo "$result"
  # {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"main","containers":["element-web","synapse","postgres"],"duration_ms":4200}

  # Deploy con compose files adicionales y branch especifico
  result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element" "release" "docker-compose.livekit.yml,docker-compose.monitoring.yml")
  echo "$result"
  # {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"release","containers":[...],"duration_ms":8100}

  # Uso desde un pipeline CI/CD
  source bash/functions/infra/docker_compose_remote_deploy.sh
  docker_compose_remote_deploy "$SSH_HOST" "$REMOTE_DIR" "$GIT_BRANCH" "$EXTRA_COMPOSE" || exit 1
  ```

notes:
  - Flujo: verificar SSH → git pull → docker-compose pull → docker-compose up -d → listar containers.
  - La verificacion SSH usa `-o BatchMode=yes -o ConnectTimeout=5` para fallar rapido sin pedir password.
  - Los compose files adicionales se pasan como `-f file1.yml -f file2.yml` a todos los subcomandos compose.
  - `docker-compose up -d` solo recrea los servicios cuya imagen o config cambio (comportamiento nativo de compose).
  - La lista de containers al final incluye TODOS los containers corriendo en el host, no solo los del stack.
  - Requiere `jq` instalado en el host remoto para serializar la lista de containers. Si no esta, `containers` sera `[]`.
  - Los mensajes de progreso van a stderr; el JSON final va a stdout.
  - Exit code 1 en cualquier fallo (SSH, git pull, compose pull, compose up); el JSON de error NO se emite — el caller debe manejar el exit code.
  - El `host` se resuelve con `~/.ssh/config` incluyendo host, user, identityfile y puerto.
  - Diferencia con `rsync_deploy`: este flujo asume que el codigo ya esta en el remoto (via git) y usa compose. `rsync_deploy` sube archivos locales sin git.

code:
  #!/usr/bin/env bash
  # docker_compose_remote_deploy — Despliega un stack Docker Compose en un host remoto via SSH
  set -euo pipefail

  docker_compose_remote_deploy() {
      local host="$1"
      local remote_dir="$2"
      local branch="${3:-main}"
      local compose_files="${4:-}"

      if [[ -z "$host" || -z "$remote_dir" ]]; then
          echo "docker_compose_remote_deploy: se requieren host y remote_dir" >&2
          return 1
      fi

      local start_ts
      start_ts=$(date +%s)

      # 1. Verificar conectividad SSH
      echo "docker_compose_remote_deploy: verificando conectividad SSH a '$host'..." >&2
      if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then
          echo "docker_compose_remote_deploy: no se puede conectar a '$host' via SSH" >&2
          return 1
      fi

      # 2. Git pull en el host remoto
      echo "docker_compose_remote_deploy: git pull origin $branch en '$remote_dir'..." >&2
      if ! ssh "$host" "cd '$remote_dir' && git pull origin '$branch'" >&2; then
          echo "docker_compose_remote_deploy: git pull falló en '$host:$remote_dir'" >&2
          return 1
      fi

      # 3. Construir los argumentos -f para docker-compose
      local compose_args="-f docker-compose.yml"
      if [[ -n "$compose_files" ]]; then
          local IFS=","
          local extra_file
          for extra_file in $compose_files; do
              extra_file="${extra_file// /}"  # trim spaces
              if [[ -n "$extra_file" ]]; then
                  compose_args="$compose_args -f $extra_file"
              fi
          done
          unset IFS
      fi

      # 4. docker-compose pull
      echo "docker_compose_remote_deploy: actualizando imagenes ($compose_args)..." >&2
      if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args pull" >&2; then
          echo "docker_compose_remote_deploy: docker-compose pull falló en '$host:$remote_dir'" >&2
          return 1
      fi

      # 5. docker-compose up -d
      echo "docker_compose_remote_deploy: levantando servicios ($compose_args)..." >&2
      if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args up -d" >&2; then
          echo "docker_compose_remote_deploy: docker-compose up -d falló en '$host:$remote_dir'" >&2
          return 1
      fi

      # 6. Recopilar containers corriendo tras el deploy
      local containers_json
      containers_json=$(ssh "$host" \
          "docker ps --format '{{.Names}}' 2>/dev/null | jq -R . | jq -sc ." 2>/dev/null || echo '[]')

      local end_ts
      end_ts=$(date +%s)
      local duration_ms=$(( (end_ts - start_ts) * 1000 ))

      # Emitir JSON a stdout
      printf '{"status":"ok","host":"%s","remote_dir":"%s","branch":"%s","containers":%s,"duration_ms":%d}\n' \
          "$host" \
          "$remote_dir" \
          "$branch" \
          "$containers_json" \
          "$duration_ms"
  }

  if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
      docker_compose_remote_deploy "$@"
  fi

params_schema:
  {"params":[{"name":"host","desc":"alias SSH del host remoto definido en ~/.ssh/config (ej: prod-server)"},{"name":"remote_dir","desc":"ruta absoluta en el host donde esta el repo con docker-compose.yml (ej: /opt/apps/element)"},{"name":"branch","desc":"branch de git a hacer pull; default 'main'"},{"name":"compose_files","desc":"archivos compose adicionales separados por coma (ej: 'docker-compose.livekit.yml,docker-compose.monitoring.yml'); si vacio usa solo docker-compose.yml"}],"output":"JSON con status ('ok'), host, remote_dir, branch, containers (array de nombres corriendo tras el deploy), duration_ms"}


id: docker_cp_file_bash_infra
name: docker_cp_file
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: docker_cp_file(local_path: string, container_name: string, dest_path: string) -> string
tags: ["docker","cp","copy","file","container","transfer","infra","bash"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/docker_cp_file.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 34cf8d182c9154609e1f09f14513194ccc85fc91b55b155bf06f0bd6ffe90528
description:
  Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide. Imprime JSON con local_size y remote_size a stdout. Sale con exit code 1 si docker cp falla o los tamaños difieren.

example:
  ```bash
  source functions/infra/docker_cp_file.sh

  result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
  echo "$result"
  # {"local_size":524288,"remote_size":524288}

  local_size=$(echo "$result" | grep -o '"local_size":[0-9]*' | cut -d: -f2)
  ```

notes:
  La verificación de tamaño usa `docker exec stat -c%s` sobre el contenedor destino. Si `stat` no está disponible en el contenedor, `remote_size` será -1 y la función fallará.

  Output a stdout: JSON minificado con campos `local_size` y `remote_size` (enteros, bytes).

  Usa `printf` en lugar de `echo` para garantizar que no haya newline extra en el JSON.

code:
  # docker_cp_file
  # --------------
  # Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide.
  # Imprime JSON con local_size y remote_size a stdout si la copia es exitosa.
  # Sale con exit code 1 si docker cp falla o si los tamaños no coinciden.
  #
  # USO (sourced):
  #   source docker_cp_file.sh
  #   result=$(docker_cp_file /ruta/local.db metabase /dest/path.db)

  docker_cp_file() {
      local local_path="$1"
      local container_name="$2"
      local dest_path="$3"

      if ! docker cp "$local_path" "${container_name}:${dest_path}" 2>/dev/null; then
          echo "docker_cp_file: fallo al copiar '$local_path' a '${container_name}:${dest_path}'" >&2
          return 1
      fi

      local local_size
      local_size=$(stat -c%s "$local_path")

      local remote_size
      remote_size=$(docker exec "$container_name" stat -c%s "$dest_path" 2>/dev/null || echo "-1")

      if [ "$local_size" != "$remote_size" ]; then
          echo "docker_cp_file: tamaños no coinciden (local=${local_size}, remoto=${remote_size})" >&2
          return 1
      fi

      printf '{"local_size":%s,"remote_size":%s}' "$local_size" "$remote_size"
  }

params_schema:
  {"params":[{"name":"local_path","desc":"ruta del archivo local a copiar"},{"name":"container_name","desc":"nombre del contenedor Docker destino"},{"name":"dest_path","desc":"ruta destino dentro del contenedor"}],"output":"JSON con local_size y remote_size en bytes"}


id: frontend_doctor_bash_infra
name: frontend_doctor
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: frontend_doctor(project_dir: string) -> diagnostics_stdout
tags: ["frontend","mantine","doctor","diagnostics","health","validation"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/frontend_doctor.sh
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
props: []
emits: []
variant: []
content_hash: eb61d0f0248ba6d23bbde74add06154c387aed91578408f487ae46479474794f
description:
  Diagnostica la salud de un proyecto frontend Mantine. Verifica Node, React, Mantine, PostCSS, TypeScript, vite.config y detecta residuos de shadcn/@base-ui. Imprime tabla de checks con exit code 0/1.

example:
  ```bash
  # Diagnosticar un proyecto
  bash frontend_doctor.sh ./apps/rapid_dashboards/frontend

  # Output:
  # === Frontend Doctor: ./apps/rapid_dashboards/frontend ===
  #
  #   ✓  Node >= 18                          22.12.0
  #   ✓  Package manager detected            pnpm
  #   ✓  node_modules present
  #   ✓  @mantine/core                       7.17.0
  #   ✓  @mantine/hooks
  #   ✓  @mantine/charts
  #   ✓  React >= 18                         19.2.4
  #   ✓  postcss.config present
  #   ✓  TypeScript >= 5                     6.0.2
  #   ✓  vite.config present
  #   ✓  No shadcn residual
  #   ✓  No @base-ui residual
  #
  #   Resultado: todo OK
  ```

notes:
  Checks informativos, no modifica nada. Util para validar que un proyecto esta correctamente configurado despues de instalar Mantine o migrar desde shadcn. Exit code 0 si todo OK, 1 si hay problemas.

code:
  # frontend_doctor
  # ----------------
  # Diagnostica la salud de un proyecto frontend Mantine.
  # Verifica dependencias, configuracion y versiones.
  # Imprime tabla de checks y retorna exit code 0 (ok) o 1 (fallos).
  #
  # USO (sourced):
  #   source frontend_doctor.sh
  #   frontend_doctor /path/to/frontend
  #
  # USO (directo):
  #   bash frontend_doctor.sh /path/to/frontend

  frontend_doctor() {
      local project_dir="$1"
      local failures=0

      if [ -z "$project_dir" ]; then
          echo "frontend_doctor: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -f "$project_dir/package.json" ]; then
          echo "frontend_doctor: no existe package.json en $project_dir" >&2
          return 1
      fi

      echo "=== Frontend Doctor: $project_dir ==="
      echo ""

      # Helper: check y reportar
      _check() {
          local label="$1"
          local ok="$2"
          local detail="$3"
          if [ "$ok" = "1" ]; then
              printf "  ✓  %-35s %s\n" "$label" "$detail"
          else
              printf "  ✗  %-35s %s\n" "$label" "$detail"
              ((failures++))
          fi
      }

      # 1. Node >= 18
      local node_ver=""
      local node_ok=0
      if command -v node &>/dev/null; then
          node_ver=$(node -v 2>/dev/null | sed 's/v//')
          local node_major=$(echo "$node_ver" | cut -d. -f1)
          [ "$node_major" -ge 18 ] 2>/dev/null && node_ok=1
      fi
      _check "Node >= 18" "$node_ok" "${node_ver:-not found}"

      # 2. Package manager
      local pm_ok=0
      local pm_name="none"
      if [ -f "$project_dir/pnpm-lock.yaml" ]; then
          pm_name="pnpm"; pm_ok=1
      elif [ -f "$project_dir/yarn.lock" ]; then
          pm_name="yarn"; pm_ok=1
      elif [ -f "$project_dir/package-lock.json" ]; then
          pm_name="npm"; pm_ok=1
      fi
      _check "Package manager detected" "$pm_ok" "$pm_name"

      # 3. node_modules existe
      local nm_ok=0
      [ -d "$project_dir/node_modules" ] && nm_ok=1
      _check "node_modules present" "$nm_ok" ""

      # 4. @mantine/core instalado
      local mantine_ok=0
      local mantine_ver=""
      if [ -f "$project_dir/node_modules/@mantine/core/package.json" ]; then
          mantine_ver=$(node -e "console.log(require('$project_dir/node_modules/@mantine/core/package.json').version)" 2>/dev/null)
          mantine_ok=1
      fi
      _check "@mantine/core" "$mantine_ok" "${mantine_ver:-not installed}"

      # 5. @mantine/hooks
      local hooks_ok=0
      [ -d "$project_dir/node_modules/@mantine/hooks" ] && hooks_ok=1
      _check "@mantine/hooks" "$hooks_ok" ""

      # 6. @mantine/charts
      local charts_ok=0
      [ -d "$project_dir/node_modules/@mantine/charts" ] && charts_ok=1
      _check "@mantine/charts" "$charts_ok" ""

      # 7. React >= 18
      local react_ok=0
      local react_ver=""
      if [ -f "$project_dir/node_modules/react/package.json" ]; then
          react_ver=$(node -e "console.log(require('$project_dir/node_modules/react/package.json').version)" 2>/dev/null)
          local react_major=$(echo "$react_ver" | cut -d. -f1)
          [ "$react_major" -ge 18 ] 2>/dev/null && react_ok=1
      fi
      _check "React >= 18" "$react_ok" "${react_ver:-not found}"

      # 8. postcss.config presente
      local postcss_ok=0
      if [ -f "$project_dir/postcss.config.cjs" ] || [ -f "$project_dir/postcss.config.js" ] || [ -f "$project_dir/postcss.config.mjs" ]; then
          postcss_ok=1
      fi
      _check "postcss.config present" "$postcss_ok" ""

      # 9. TypeScript >= 5
      local ts_ok=0
      local ts_ver=""
      if [ -f "$project_dir/node_modules/typescript/package.json" ]; then
          ts_ver=$(node -e "console.log(require('$project_dir/node_modules/typescript/package.json').version)" 2>/dev/null)
          local ts_major=$(echo "$ts_ver" | cut -d. -f1)
          [ "$ts_major" -ge 5 ] 2>/dev/null && ts_ok=1
      fi
      _check "TypeScript >= 5" "$ts_ok" "${ts_ver:-not found}"

      # 10. vite.config presente
      local vite_ok=0
      if [ -f "$project_dir/vite.config.ts" ] || [ -f "$project_dir/vite.config.js" ]; then
          vite_ok=1
      fi
      _check "vite.config present" "$vite_ok" ""

      # 11. Shadcn residual (warning)
      local shadcn_clean=1
      if [ -f "$project_dir/components.json" ]; then
          shadcn_clean=0
      fi
      _check "No shadcn residual" "$shadcn_clean" "$([ "$shadcn_clean" = "0" ] && echo 'components.json found')"

      # 12. @base-ui residual (warning)
      local baseui_clean=1
      if [ -d "$project_dir/node_modules/@base-ui" ]; then
          baseui_clean=0
      fi
      _check "No @base-ui residual" "$baseui_clean" "$([ "$baseui_clean" = "0" ] && echo '@base-ui still installed')"

      echo ""
      if [ "$failures" -eq 0 ]; then
          echo "  Resultado: todo OK"
          return 0
      else
          echo "  Resultado: $failures problema(s) encontrado(s)"
          return 1
      fi
  }

  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      frontend_doctor "$@"
  fi

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto frontend con package.json"}],"output":"tabla de checks con ✓/✗ por cada validación y resumen final"}


id: gitea_add_collaborator_bash_infra
name: gitea_add_collaborator
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_add_collaborator(owner: string, repo: string, username: string, permission: string) -> void
tags: ["gitea","git","collaborator","permission","repo","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_add_collaborator.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: 26ad480468b3d06fc2b01b02a9c287654037e07b880cf98c216235437a081bf2
description:
  Añade un colaborador a un repositorio Gitea con el nivel de permisos indicado. Silencioso si el colaborador ya existe (422).

example:
  ```bash
  source bash/functions/infra/gitea_add_collaborator.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Añadir colaborador con permiso admin (default)
  gitea_add_collaborator "myorg" "my-app" "egutierrez"

  # Añadir colaborador con permiso de solo lectura
  gitea_add_collaborator "myorg" "my-app" "reviewer" "read"
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - Un 422 de la API indica que el usuario ya es colaborador — se trata como éxito silencioso.
  - La función no produce salida a stdout; los mensajes informativos van a stderr.
  - Nivel `admin` da acceso completo al repo incluyendo settings.

code:
  #!/usr/bin/env bash
  # gitea_add_collaborator — Añade un colaborador a un repositorio Gitea

  gitea_add_collaborator() {
      local owner="$1"
      local repo="$2"
      local username="$3"
      local permission="${4:-admin}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_add_collaborator: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_add_collaborator: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" || -z "$repo" || -z "$username" ]]; then
          echo "gitea_add_collaborator: se requieren owner, repo y username" >&2
          return 1
      fi

      local payload
      payload=$(printf '{"permission":"%s"}' "$permission")

      echo "gitea_add_collaborator: añadiendo '$username' a '$owner/$repo' con permiso '$permission'..." >&2

      local response http_code
      response=$(curl -s -w "\n%{http_code}" \
          -X PUT \
          -H "Content-Type: application/json" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          -d "$payload" \
          "${GITEA_URL}/api/v1/repos/${owner}/${repo}/collaborators/${username}")

      http_code=$(echo "$response" | tail -n1)
      local body
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" == "204" || "$http_code" == "200" ]]; then
          echo "gitea_add_collaborator: '$username' añadido a '$owner/$repo'" >&2
          return 0
      fi

      if [[ "$http_code" == "422" ]]; then
          echo "gitea_add_collaborator: '$username' ya es colaborador de '$owner/$repo' (silencioso)" >&2
          return 0
      fi

      echo "gitea_add_collaborator: error (HTTP ${http_code}): ${body}" >&2
      return 1
  }

params_schema:
  {"params":[{"name":"owner","desc":"usuario u organización propietaria del repositorio"},{"name":"repo","desc":"nombre del repositorio"},{"name":"username","desc":"nombre de usuario del colaborador a añadir"},{"name":"permission","desc":"nivel de permisos: 'read', 'write' o 'admin' (default: admin)"}],"output":"vacío — efectos observables a través de la API de Gitea"}


id: gitea_create_repo_bash_infra
name: gitea_create_repo
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_create_repo(owner: string, name: string, private: string, description: string) -> string
tags: ["gitea","git","repo","create","infra","api"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_create_repo.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: e321b9937e6be33965e10b271b2f1a9b834d10112a1312af00cb6df272a8938d
description:
  Crea un repositorio en Gitea para un owner. Intenta crearlo en org primero; si el owner no es una org (404/422), lo crea en el usuario autenticado. No falla fatalmente si el repo ya existe (409).

example:
  ```bash
  source bash/functions/infra/gitea_create_repo.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Crear repo público en org o usuario
  repo_json=$(gitea_create_repo "myorg" "my-app")

  # Crear repo privado con descripción
  repo_json=$(gitea_create_repo "myorg" "my-app" "true" "Mi aplicación principal")

  # Extraer la URL del clon
  clone_url=$(echo "$repo_json" | jq -r '.clone_url')
  ```

notes:
  - Requiere variables de entorno `GITEA_URL` y `GITEA_TOKEN` seteadas antes de invocar.
  - El fallback org → usuario ocurre con HTTP 404 o 422 en el endpoint de orgs.
  - Un 409 se reporta a stderr pero la función retorna 0 — el repo ya existe es una condición aceptable para idempotencia.
  - Los mensajes informativos van a stderr; el JSON de respuesta va a stdout.

code:
  #!/usr/bin/env bash
  # gitea_create_repo — Crea un repositorio en Gitea para un owner (org o usuario)

  gitea_create_repo() {
      local owner="$1"
      local name="$2"
      local private="${3:-false}"
      local description="${4:-}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_create_repo: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_create_repo: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" || -z "$name" ]]; then
          echo "gitea_create_repo: se requieren owner y name" >&2
          return 1
      fi

      local payload
      payload=$(printf '{"name":"%s","private":%s,"description":"%s","auto_init":false}' \
          "$name" "$private" "$description")

      echo "gitea_create_repo: intentando crear '$owner/$name' en org..." >&2

      local response http_code
      response=$(curl -s -w "\n%{http_code}" \
          -X POST \
          -H "Content-Type: application/json" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          -d "$payload" \
          "${GITEA_URL}/api/v1/orgs/${owner}/repos")

      http_code=$(echo "$response" | tail -n1)
      local body
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" == "201" ]]; then
          echo "gitea_create_repo: repo '$owner/$name' creado en org" >&2
          echo "$body"
          return 0
      fi

      if [[ "$http_code" == "409" ]]; then
          echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
          echo "$body"
          return 0
      fi

      if [[ "$http_code" == "404" || "$http_code" == "422" ]]; then
          echo "gitea_create_repo: org no encontrada (${http_code}), intentando en usuario..." >&2
          response=$(curl -s -w "\n%{http_code}" \
              -X POST \
              -H "Content-Type: application/json" \
              -H "Authorization: token ${GITEA_TOKEN}" \
              -d "$payload" \
              "${GITEA_URL}/api/v1/user/repos")

          http_code=$(echo "$response" | tail -n1)
          body=$(echo "$response" | head -n -1)

          if [[ "$http_code" == "201" ]]; then
              echo "gitea_create_repo: repo '$owner/$name' creado en usuario" >&2
              echo "$body"
              return 0
          fi

          if [[ "$http_code" == "409" ]]; then
              echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
              echo "$body"
              return 0
          fi

          echo "gitea_create_repo: error al crear en usuario (HTTP ${http_code}): ${body}" >&2
          return 1
      fi

      echo "gitea_create_repo: error inesperado (HTTP ${http_code}): ${body}" >&2
      return 1
  }

params_schema:
  {"params":[{"name":"owner","desc":"usuario u organización propietaria del repo"},{"name":"name","desc":"nombre del repositorio a crear"},{"name":"private","desc":"si el repo es privado, 'true' o 'false' (default: false)"},{"name":"description","desc":"descripción del repositorio (opcional)"}],"output":"JSON del repositorio creado según la API de Gitea"}


id: gitea_create_webhook_bash_infra
name: gitea_create_webhook
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_create_webhook(owner: string, repo: string, target_url: string, secret?: string) -> json
description: Crea un webhook de push en un repositorio Gitea. El webhook notifica a target_url en cada push.
tags: ["gitea","webhook","push","deploy","ci","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_create_webhook.sh
created_at: 2026-04-12T12:48:21Z
updated_at: 2026-04-12T12:48:21Z
props: []
emits: []
variant: []
content_hash: 094cae1ada836289be900634ee31c4a71b6d1b05a6f3ea4d64e7472ef9079db3
example:
  ```bash
  source bash/functions/infra/gitea_create_webhook.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Crear webhook para auto-deploy
  gitea_create_webhook "myorg" "dag_engine" "http://vps:9090/webhook/push" "mi_secreto"
  # {"webhook_id":42,"owner":"myorg","repo":"dag_engine","target_url":"http://vps:9090/webhook/push"}
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` como variables de entorno.
  - Solo escucha eventos `push`. Para otros eventos, modificar el array `events` en el payload.
  - Si el webhook ya existe para la misma URL, Gitea crea uno duplicado (no es idempotente).

code:
  #!/usr/bin/env bash
  # gitea_create_webhook — Crea un webhook de push en un repositorio Gitea
  set -euo pipefail

  gitea_create_webhook() {
      local owner="$1"
      local repo="$2"
      local target_url="$3"
      local secret="${4:-}"

      if [[ -z "$owner" || -z "$repo" || -z "$target_url" ]]; then
          echo "usage: gitea_create_webhook <owner> <repo> <target_url> [secret]" >&2
          return 1
      fi

      local gitea_url="${GITEA_URL:?GITEA_URL no seteada}"
      local gitea_token="${GITEA_TOKEN:?GITEA_TOKEN no seteada}"

      # Payload JSON para el webhook
      local payload
      payload=$(cat <<EOF
  {
      "type": "gitea",
      "active": true,
      "events": ["push"],
      "config": {
          "url": "$target_url",
          "content_type": "json",
          "secret": "$secret"
      }
  }
  EOF
  )

      local response http_code body
      response=$(curl -s -w "\n%{http_code}" \
          -X POST \
          -H "Authorization: token $gitea_token" \
          -H "Content-Type: application/json" \
          -d "$payload" \
          "${gitea_url}/api/v1/repos/${owner}/${repo}/hooks")

      http_code=$(echo "$response" | tail -1)
      body=$(echo "$response" | sed '$d')

      if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
          # Extraer webhook ID del response
          local webhook_id
          webhook_id=$(echo "$body" | grep -oP '"id":\s*\K[0-9]+' | head -1)
          printf '{"webhook_id":%s,"owner":"%s","repo":"%s","target_url":"%s"}\n' \
              "$webhook_id" "$owner" "$repo" "$target_url"
      else
          echo "gitea_create_webhook: HTTP $http_code — $body" >&2
          return 1
      fi
  }

  if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
      gitea_create_webhook "$@"
  fi

params_schema:
  {"params":[{"name":"owner","desc":"usuario u organización propietaria del repositorio"},{"name":"repo","desc":"nombre del repositorio"},{"name":"target_url","desc":"URL que recibirá el POST del webhook en cada push"},{"name":"secret","desc":"secreto compartido para firmar el payload (opcional)"}],"output":"JSON con webhook_id, owner, repo, target_url"}


id: gitea_list_repos_bash_infra
name: gitea_list_repos
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_list_repos(owner: string) -> string
tags: ["gitea","git","repo","list","org","user","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_list_repos.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: a8464110043a3a2761afa00e41da77627c4a5adb949259165ecb89da9303e428
description:
  Lista repositorios de un owner en Gitea. Intenta listar como org primero; si falla, lista como usuario. Imprime una línea por repo en formato name<TAB>html_url<TAB>description.

example:
  ```bash
  source bash/functions/infra/gitea_list_repos.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Listar todos los repos de una org
  gitea_list_repos "myorg"
  # my-app    https://git.example.com/myorg/my-app    Mi aplicación principal
  # infra     https://git.example.com/myorg/infra

  # Iterar sobre los repos
  while IFS=$'\t' read -r name url desc; do
      echo "Repo: $name — $url"
  done < <(gitea_list_repos "myorg")
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - Usa `jq` si está disponible; fallback con grep/sed en caso contrario.
  - El límite es 50 repos por página. Para owners con más de 50 repos habría que implementar paginación.
  - Los mensajes informativos van a stderr; los datos tabulados van a stdout.

code:
  #!/usr/bin/env bash
  # gitea_list_repos — Lista repositorios de un owner (org o usuario) en Gitea

  gitea_list_repos() {
      local owner="$1"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_list_repos: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_list_repos: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$owner" ]]; then
          echo "gitea_list_repos: se requiere owner" >&2
          return 1
      fi

      echo "gitea_list_repos: listando repos de '$owner' (intentando org)..." >&2

      local response http_code body
      response=$(curl -s -w "\n%{http_code}" \
          -H "Authorization: token ${GITEA_TOKEN}" \
          "${GITEA_URL}/api/v1/orgs/${owner}/repos?limit=50")

      http_code=$(echo "$response" | tail -n1)
      body=$(echo "$response" | head -n -1)

      if [[ "$http_code" != "200" ]]; then
          echo "gitea_list_repos: org no encontrada (${http_code}), intentando usuario..." >&2
          response=$(curl -s -w "\n%{http_code}" \
              -H "Authorization: token ${GITEA_TOKEN}" \
              "${GITEA_URL}/api/v1/users/${owner}/repos?limit=50")

          http_code=$(echo "$response" | tail -n1)
          body=$(echo "$response" | head -n -1)

          if [[ "$http_code" != "200" ]]; then
              echo "gitea_list_repos: error listando repos de usuario (HTTP ${http_code}): ${body}" >&2
              return 1
          fi
      fi

      # Formatear salida como name\thtml_url\tdescription
      if command -v jq &>/dev/null; then
          echo "$body" | jq -r '.[] | [.name, .html_url, (.description // "")] | @tsv'
      else
          # Fallback sin jq: extraer campos básicos con grep/sed
          echo "$body" | grep -o '"name":"[^"]*"\|"html_url":"[^"]*"\|"description":"[^"]*"' \
              | paste - - - | sed 's/"name":"//;s/"html_url":"//;s/"description":"//;s/"//g'
      fi
  }

params_schema:
  {"params":[{"name":"owner","desc":"nombre del usuario u organización cuyos repos se listan"}],"output":"una línea por repositorio con columnas separadas por tabulador: name, html_url, description"}


id: gitea_push_directory_bash_infra
name: gitea_push_directory
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: gitea_push_directory(directory: string, owner: string, repo: string, branch: string) -> void
tags: ["gitea","git","push","directory","sync","infra","repo"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/gitea_push_directory.sh
created_at: 2026-04-05T23:01:37Z
updated_at: 2026-04-05T23:01:37Z
props: []
emits: []
variant: []
content_hash: 3f0868b69167a6bac201dd5ba6e19b94461be9e510d14d55f629fc778c0cfde3
description:
  Inicializa git en un directorio local y lo sube a un repositorio Gitea existente. Si el directorio ya tiene .git, actualiza el remote y pushea cambios pendientes. Protege registry.db añadiéndolo al .gitignore antes del commit.

example:
  ```bash
  source bash/functions/infra/gitea_push_directory.sh

  export GITEA_URL="https://git.example.com"
  export GITEA_TOKEN="$(pass agentes/dataforge-token)"

  # Subir directorio a repo existente
  gitea_push_directory "/home/lucas/myproject" "myorg" "my-app"

  # Subir a rama específica
  gitea_push_directory "/home/lucas/myproject" "myorg" "my-app" "develop"
  ```

notes:
  - Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
  - El token se embebe en la URL del remote para autenticación (nunca se imprime a stdout/stderr, se enmascara con ***).
  - Si `registry.db` existe en el directorio, se añade automáticamente al `.gitignore` local.
  - Si el `.git` ya existe con un remote diferente, se redirige al repo indicado sin perder el historial local.
  - Usa `--force-with-lease` para el primer push y fallback a push normal (para repos vacíos recién creados).
  - El commit se firma con `agent@fn-registry` si no hay configuración git en el entorno.

code:
  #!/usr/bin/env bash
  # gitea_push_directory — Inicializa git en un directorio y lo sube a un repo Gitea existente

  gitea_push_directory() {
      local directory="$1"
      local owner="$2"
      local repo="$3"
      local branch="${4:-main}"

      if [[ -z "${GITEA_URL:-}" ]]; then
          echo "gitea_push_directory: GITEA_URL no está seteada" >&2
          return 1
      fi
      if [[ -z "${GITEA_TOKEN:-}" ]]; then
          echo "gitea_push_directory: GITEA_TOKEN no está seteado" >&2
          return 1
      fi
      if [[ -z "$directory" || -z "$owner" || -z "$repo" ]]; then
          echo "gitea_push_directory: se requieren directory, owner y repo" >&2
          return 1
      fi
      if [[ ! -d "$directory" ]]; then
          echo "gitea_push_directory: directorio '$directory' no existe" >&2
          return 1
      fi

      # Construir URL con credenciales embebidas para autenticación
      local gitea_host
      gitea_host=$(echo "$GITEA_URL" | sed 's|https\?://||')
      local remote_url="https://${GITEA_TOKEN}@${gitea_host}/${owner}/${repo}.git"
      local display_url="https://***@${gitea_host}/${owner}/${repo}.git"

      echo "gitea_push_directory: procesando '$directory' → '$owner/$repo' (rama: $branch)..." >&2

      # Añadir registry.db al .gitignore local si existe en el directorio
      if [[ -f "$directory/registry.db" ]]; then
          echo "gitea_push_directory: añadiendo registry.db al .gitignore..." >&2
          if [[ ! -f "$directory/.gitignore" ]] || ! grep -qxF "registry.db" "$directory/.gitignore"; then
              echo "registry.db" >> "$directory/.gitignore"
          fi
      fi

      # Gestionar estado del repositorio git
      if [[ -d "$directory/.git" ]]; then
          local existing_remote
          existing_remote=$(git -C "$directory" remote get-url origin 2>/dev/null || echo "")

          if [[ -z "$existing_remote" ]]; then
              echo "gitea_push_directory: añadiendo remote origin..." >&2
              git -C "$directory" remote add origin "$remote_url"
          else
              # Comparar remote sin token para detectar si apunta al mismo repo
              local clean_existing
              clean_existing=$(echo "$existing_remote" | sed 's|https://[^@]*@||;s|https://||')
              local clean_target="${gitea_host}/${owner}/${repo}.git"

              if [[ "$clean_existing" != "$clean_target" ]]; then
                  echo "gitea_push_directory: remote apunta a otro destino ('$clean_existing'), actualizando..." >&2
                  git -C "$directory" remote set-url origin "$remote_url"
              else
                  echo "gitea_push_directory: remote ya apunta al destino correcto, actualizando token..." >&2
                  git -C "$directory" remote set-url origin "$remote_url"
              fi
          fi
      else
          echo "gitea_push_directory: inicializando nuevo repositorio git..." >&2
          git -C "$directory" init
          git -C "$directory" remote add origin "$remote_url"
      fi

      # Configurar rama por defecto
      git -C "$directory" checkout -B "$branch" 2>/dev/null || true

      # Añadir y commitear cambios si los hay
      git -C "$directory" add -A

      local status
      status=$(git -C "$directory" status --porcelain)

      if [[ -n "$status" ]]; then
          echo "gitea_push_directory: commiteando cambios..." >&2
          git -C "$directory" -c user.email="agent@fn-registry" -c user.name="fn-registry agent" \
              commit -m "chore: sync from fn-registry agent"
      else
          echo "gitea_push_directory: sin cambios pendientes, solo haciendo push..." >&2
      fi

      echo "gitea_push_directory: haciendo push a $display_url..." >&2
      git -C "$directory" push --set-upstream origin "$branch" --force-with-lease 2>&1 \
          | sed "s|${GITEA_TOKEN}|***|g" >&2 \
          || git -C "$directory" push --set-upstream origin "$branch" 2>&1 \
              | sed "s|${GITEA_TOKEN}|***|g" >&2

      echo "gitea_push_directory: push completado a '$owner/$repo' rama '$branch'" >&2
  }

params_schema:
  {"params":[{"name":"directory","desc":"ruta absoluta o relativa al directorio local a subir"},{"name":"owner","desc":"usuario u organización propietaria del repositorio Gitea destino"},{"name":"repo","desc":"nombre del repositorio Gitea destino (debe existir previamente)"},{"name":"branch","desc":"rama en la que hacer push (default: main)"}],"output":"vacío — efectos observables en el repositorio Gitea destino"}


id: init_uv_venv_bash_infra
name: init_uv_venv
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: init_uv_venv([project_dir: string]) -> string
tags: ["python","venv","uv","setup","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/init_uv_venv.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f0316721482ff0715ed3b38820d44506b9124ab7dbecc48b878802fe579c3dad
description:
  Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv.

example:
  ```bash
  source init_uv_venv.sh
  venv=$(init_uv_venv /home/lucas/analysis/finanzas)
  echo "Venv creado en: $venv"

  # Idempotente — si ya existe, retorna la ruta sin recrear
  venv=$(init_uv_venv .)
  ```

notes:
  Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.

code:
  # init_uv_venv
  # -------------
  # Crea un venv con uv en el directorio especificado si no existe.
  # Fallback a python -m venv si uv no esta disponible.
  # Imprime la ruta del venv a stdout.
  #
  # USO (sourced):
  #   source init_uv_venv.sh
  #   venv_path=$(init_uv_venv /path/to/project)

  init_uv_venv() {
      local project_dir="${1:-.}"
      local venv_path="${project_dir}/.venv"

      if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
          echo "$venv_path"
          return 0
      fi

      if command -v uv &>/dev/null; then
          (cd "$project_dir" && uv venv) >/dev/null 2>&1
      elif command -v python3 &>/dev/null; then
          python3 -m venv "$venv_path"
      else
          echo "init_uv_venv: ni uv ni python3 disponibles" >&2
          return 1
      fi

      if [ ! -f "$venv_path/bin/python" ]; then
          echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
          return 1
      fi

      echo "$venv_path"
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto donde crear el venv (default: directorio actual)"}],"output":"ruta absoluta del venv creado o existente"}


id: install_android_sdk_bash_infra
name: install_android_sdk
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_android_sdk() -> void
tags: ["android","sdk","jdk","java","install","infra","mobile"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_android_sdk.sh
created_at: 2026-04-06T16:40:15Z
updated_at: 2026-04-06T16:40:15Z
props: []
emits: []
variant: []
content_hash: 93656abf9e21932065937e90abf5816f1a8830a61018b6276952c7e3101d48a7
params_schema: {"output":"sin salida estructurada; imprime progreso y resumen final con rutas de instalacion"}
description:
  Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source.

example:
  ```bash
  # Instalacion en directorio por defecto ($HOME/android-sdk)
  source install_android_sdk.sh

  # Instalacion en directorio personalizado
  ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh

  # Si ya esta instalado:
  # Android SDK ya instalado en: /home/user/android-sdk

  # Instalacion completa imprime:
  # Descargando JDK 17...
  # JDK 17 instalado: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
  # Descargando Android cmdline-tools...
  # cmdline-tools instalados
  # Aceptando licencias de Android SDK...
  # Instalando platform-tools, platforms;android-34, build-tools;34.0.0...
  #
  # Android SDK instalado en: /home/user/android-sdk
  # JDK 17: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
  # Para activar: source /home/user/android-sdk/env.sh

  # Activar entorno en sesion actual
  source ~/android-sdk/env.sh
  ```

notes:
  Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.

  El JDK se descarga desde Adoptium (Eclipse Temurin) via su API oficial. La URL de cmdline-tools apunta a la version 11076708 (2024). Si Google actualiza la version, cambiar la URL con el nuevo numero de build.

  La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools con estructura `cmdline-tools/bin/...` pero sdkmanager espera estar en `cmdline-tools/latest/bin/sdkmanager` para que Android Studio y otras herramientas lo detecten correctamente.

  El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.

  Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.

code:
  #!/usr/bin/env bash
  # install_android_sdk — Descarga e instala Android SDK command-line tools y JDK 17
  # localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk).
  set -euo pipefail

  install_android_sdk() {
      local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
      local tmp_dir
      tmp_dir="$(mktemp -d)"

      # Limpia temporales al salir
      trap 'rm -rf "$tmp_dir"' EXIT

      # 1. Verifica si ya está instalado
      if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
          if JAVA_HOME="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1)" \
             "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" --version &>/dev/null; then
              echo "Android SDK ya instalado en: $sdk_dir"
              return 0
          fi
      fi

      mkdir -p "$sdk_dir"

      # 2. Descarga JDK 17 si no existe
      local jdk_dir
      jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"

      if [[ -z "$jdk_dir" ]]; then
          echo "Descargando JDK 17..."
          local jdk_tar="$tmp_dir/jdk17.tar.gz"
          local jdk_url="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"

          if ! curl -fL --progress-bar -o "$jdk_tar" "$jdk_url"; then
              echo "ERROR: fallo al descargar JDK 17 desde $jdk_url" >&2
              return 1
          fi

          mkdir -p "$sdk_dir/jdk-17"
          echo "Extrayendo JDK 17..."
          if ! tar -xzf "$jdk_tar" -C "$sdk_dir/jdk-17"; then
              echo "ERROR: fallo al extraer JDK 17" >&2
              return 1
          fi

          jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
          if [[ -z "$jdk_dir" ]]; then
              echo "ERROR: no se encontro directorio jdk-17* tras la extraccion" >&2
              return 1
          fi

          if ! JAVA_HOME="$jdk_dir" "$jdk_dir/bin/java" -version &>/dev/null; then
              echo "ERROR: java -version fallo tras instalar JDK" >&2
              return 1
          fi
          echo "JDK 17 instalado: $jdk_dir"
      else
          echo "JDK 17 ya presente: $jdk_dir"
      fi

      export JAVA_HOME="$jdk_dir"

      # 3. Descarga Android cmdline-tools si no existen
      if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
          echo "Descargando Android cmdline-tools..."
          local tools_zip="$tmp_dir/cmdline-tools.zip"
          local tools_url="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"

          if ! curl -fL --progress-bar -o "$tools_zip" "$tools_url"; then
              echo "ERROR: fallo al descargar Android cmdline-tools desde $tools_url" >&2
              return 1
          fi

          local tools_tmp="$tmp_dir/cmdline-tools-extracted"
          mkdir -p "$tools_tmp"
          echo "Extrayendo cmdline-tools..."
          if ! unzip -q "$tools_zip" -d "$tools_tmp"; then
              echo "ERROR: fallo al extraer cmdline-tools" >&2
              return 1
          fi

          # La estructura del zip es cmdline-tools/bin/..., reorganizar a cmdline-tools/latest/
          mkdir -p "$sdk_dir/cmdline-tools"
          if [[ -d "$tools_tmp/cmdline-tools" ]]; then
              mv "$tools_tmp/cmdline-tools" "$sdk_dir/cmdline-tools/latest"
          else
              echo "ERROR: estructura inesperada en el zip de cmdline-tools" >&2
              return 1
          fi

          if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
              echo "ERROR: sdkmanager no encontrado tras extraer cmdline-tools" >&2
              return 1
          fi
          echo "cmdline-tools instalados"
      else
          echo "cmdline-tools ya presentes"
      fi

      local sdkmanager="$sdk_dir/cmdline-tools/latest/bin/sdkmanager"
      export ANDROID_HOME="$sdk_dir"
      export ANDROID_SDK_ROOT="$sdk_dir"
      export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"

      # 4. Acepta licencias e instala paquetes necesarios
      echo "Aceptando licencias de Android SDK..."
      if ! yes | "$sdkmanager" --licenses; then
          echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
          return 1
      fi

      echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
      if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
          echo "ERROR: fallo al instalar paquetes de Android SDK" >&2
          return 1
      fi

      # 5. Genera archivo de entorno
      local env_file="$sdk_dir/env.sh"
      cat > "$env_file" <<EOF
  export JAVA_HOME="$JAVA_HOME"
  export ANDROID_HOME="$sdk_dir"
  export ANDROID_SDK_ROOT="$sdk_dir"
  export PATH="\$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:\$PATH"
  EOF

      # 6. Resumen final
      echo ""
      echo "Android SDK instalado en: $sdk_dir"
      echo "JDK 17: $JAVA_HOME"
      echo "Para activar: source $sdk_dir/env.sh"
  }

  install_android_sdk


id: install_cpp_deps_bash_infra
name: install_cpp_deps
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_cpp_deps() -> void
tags: ["cpp","dependencies","setup","cmake","imgui"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_cpp_deps.sh
created_at: 2026-04-06T21:31:43Z
updated_at: 2026-04-06T21:31:43Z
props: []
emits: []
variant: []
content_hash: 012666be7f45769da894947f5e9ab7d3946f9377405d3e3204c4c12d8e1b11f1
params_schema: {"output":"Instala paquetes faltantes via apt o confirma que todo esta instalado"}
description:
  Verifica e instala las dependencias de sistema necesarias para compilar C++ con ImGui (cmake, g++, glfw, mesa)

documentation:
  # install_cpp_deps

  Verifica las dependencias necesarias para el build C++:

  - `cmake` — sistema de build

  - `g++` / `build-essential` — compilador

  - `libglfw3-dev` — windowing (GLFW)

  - `libgl1-mesa-dev` — OpenGL headers

  Tambien reporta si `mingw-w64` esta disponible para cross-compile a Windows.

  ```bash

  fn run install_cpp_deps

  ```

code:
  #!/usr/bin/env bash
  set -euo pipefail

  echo "[install_cpp_deps] Checking C++ build dependencies..."

  MISSING=()

  if ! command -v cmake &>/dev/null; then
      MISSING+=(cmake)
  else
      echo "  cmake: $(cmake --version | head -1)"
  fi

  if ! command -v g++ &>/dev/null; then
      MISSING+=(g++ build-essential)
  else
      echo "  g++: $(g++ --version | head -1)"
  fi

  if ! dpkg -s libglfw3-dev &>/dev/null 2>&1; then
      MISSING+=(libglfw3-dev)
  else
      echo "  libglfw3-dev: installed"
  fi

  if ! dpkg -s libgl1-mesa-dev &>/dev/null 2>&1; then
      MISSING+=(libgl1-mesa-dev)
  else
      echo "  libgl1-mesa-dev: installed"
  fi

  # Optional: mingw for cross-compile
  if command -v x86_64-w64-mingw32-g++ &>/dev/null; then
      echo "  mingw-w64: $(x86_64-w64-mingw32-g++ --version | head -1)"
  else
      echo "  mingw-w64: not installed (optional, for Windows cross-compile)"
  fi

  if [ ${#MISSING[@]} -eq 0 ]; then
      echo "[install_cpp_deps] All dependencies satisfied."
      exit 0
  fi

  echo ""
  echo "[install_cpp_deps] Missing packages: ${MISSING[*]}"
  echo "[install_cpp_deps] Installing..."
  sudo apt-get update -qq
  sudo apt-get install -y -qq "${MISSING[@]}"
  echo "[install_cpp_deps] Done."


id: install_go_bash_infra
name: install_go
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_go([version: string], [--force]) -> void
tags: ["bash","install","go","golang"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_go.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: d58d81b2aea781069360273400ef122cbaab3b1dabb6a990d03d0349dd9f3d5b
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_go.sh
description:
  Instala Go en Linux descargando desde go.dev/dl. Detecta arquitectura automáticamente (amd64/arm64/armv6l). Idempotente: omite la instalación si Go ya está presente (a menos que se use --force). Configura PATH en ~/.bashrc o ~/.zshrc.

example:
  ```bash
  source bash/functions/infra/install_go.sh

  # Instalar versión por defecto (1.22.0)
  install_go

  # Instalar versión específica
  install_go 1.23.0

  # Reinstalar aunque ya esté instalado
  install_go 1.22.0 --force
  ```

notes:
  Requiere `curl` y `sudo`. Instala en `/usr/local/go`. Crea `$HOME/go/{bin,src,pkg}` como GOPATH. Después de instalar, hay que recargar el shell (`source ~/.bashrc`) o abrir una nueva terminal.

code:
  #!/usr/bin/env bash
  # install_go
  # ----------
  # Instala Go en Linux. Detecta arquitectura automáticamente (amd64/arm64/armv6l).
  # Descarga desde go.dev/dl, instala en /usr/local y configura PATH en el shell config.
  #
  # USO:
  #   source install_go.sh
  #   install_go [version] [--force]
  #
  # ARGUMENTOS:
  #   version   Versión de Go a instalar (default: 1.22.0)
  #   --force   Reinstala aunque Go ya esté instalado

  install_go() {
      local version="1.22.0"
      local force=false

      for arg in "$@"; do
          case "$arg" in
              --force) force=true ;;
              *)       [[ "$arg" =~ ^[0-9] ]] && version="$arg" ;;
          esac
      done

      local go_os="linux"
      local go_arch

      # Detectar arquitectura
      local arch
      arch="$(uname -m)"
      case "$arch" in
          x86_64)        go_arch="amd64" ;;
          aarch64|arm64) go_arch="arm64" ;;
          armv6l)        go_arch="armv6l" ;;
          *)
              echo "install_go: arquitectura no soportada: ${arch}" >&2
              return 1
              ;;
      esac

      # Verificar si ya está instalado
      if command -v go &>/dev/null && [[ "$force" != true ]]; then
          local current_version
          current_version="$(go version | awk '{print $3}' | sed 's/go//')"
          echo "install_go: Go ya está instalado (versión: ${current_version}). Usa --force para reinstalar."
          return 0
      fi

      local tarball="go${version}.${go_os}-${go_arch}.tar.gz"
      local url="https://go.dev/dl/${tarball}"
      local install_dir="/usr/local"

      echo "Instalando Go ${version} para ${go_os}-${go_arch}..."

      # Eliminar versión anterior si existe
      if command -v go &>/dev/null; then
          echo "Eliminando versión anterior..."
          sudo rm -rf "${install_dir}/go"
      fi

      # Descargar en directorio temporal
      local tmp_dir
      tmp_dir="$(mktemp -d)"

      echo "Descargando ${url}..."
      if ! curl -LO --output-dir "$tmp_dir" "$url"; then
          echo "install_go: error al descargar Go ${version}. Verifica la versión en: https://go.dev/dl/" >&2
          rm -rf "$tmp_dir"
          return 1
      fi

      echo "Instalando en ${install_dir}..."
      sudo tar -C "$install_dir" -xzf "${tmp_dir}/${tarball}"
      rm -rf "$tmp_dir"

      # Configurar PATH en shell config
      local shell_config=""
      if [[ -f "$HOME/.bashrc" ]]; then
          shell_config="$HOME/.bashrc"
      elif [[ -f "$HOME/.zshrc" ]]; then
          shell_config="$HOME/.zshrc"
      fi

      if [[ -n "$shell_config" ]]; then
          if ! grep -q "export PATH=\$PATH:${install_dir}/go/bin" "$shell_config"; then
              {
                  echo ""
                  echo "# Go configuration"
                  echo "export PATH=\$PATH:${install_dir}/go/bin"
                  echo "export GOPATH=\$HOME/go"
                  echo "export PATH=\$PATH:\$GOPATH/bin"
              } >> "$shell_config"
              echo "Variables de PATH añadidas a ${shell_config}"
          else
              echo "Variables de entorno ya configuradas en ${shell_config}"
          fi
      fi

      # Crear estructura GOPATH
      mkdir -p "$HOME/go"/{bin,src,pkg}

      # Verificar instalación
      export PATH="$PATH:${install_dir}/go/bin"
      local installed_version
      installed_version="$("${install_dir}/go/bin/go" version)"
      echo ""
      echo "Go instalado correctamente: ${installed_version}"
      echo "Reinicia tu terminal o ejecuta: source ${shell_config:-~/.bashrc}"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_go "$@"
  fi

params_schema:
  {"params":[{"name":"version","desc":"versión de Go a instalar, ej: 1.22.0 (default: 1.22.0)"},{"name":"--force","desc":"flag para reinstalar aunque Go ya esté instalado"}],"output":"progreso a stdout; exit code 1 si la arquitectura no es soportada o falla la descarga"}


id: install_mantine_bash_infra
name: install_mantine
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_mantine(project_dir: string) -> void
tags: ["mantine","frontend","install","react","ui","postcss"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_mantine.sh
created_at: 2026-04-05T23:51:12Z
updated_at: 2026-04-05T23:51:12Z
props: []
emits: []
variant: []
content_hash: ff562680c5e966c914ae32e8f04b4ee0d3107b2cb786eaadb9463600bb87841c
description:
  Instala Mantine UI con todas sus dependencias (@mantine/core, hooks, charts, notifications, form) y PostCSS en un proyecto frontend. Detecta package manager por lockfile. Genera postcss.config.cjs si no existe. Idempotente.

example:
  ```bash
  # Instalar Mantine en un proyecto con pnpm
  source install_mantine.sh
  install_mantine ./apps/rapid_dashboards/frontend

  # Uso directo
  bash install_mantine.sh ./frontend
  ```

notes:
  Detecta el package manager por lockfile: pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm. Instala las dependencias core de Mantine v7+ y el stack PostCSS necesario. Si postcss.config.cjs ya existe no lo sobreescribe.

code:
  # install_mantine
  # ---------------
  # Instala dependencias de Mantine UI en un proyecto frontend.
  # Detecta package manager por lockfile (pnpm > yarn > npm).
  # Genera postcss.config.cjs si no existe.
  # Idempotente: no reinstala si ya estan presentes.
  #
  # USO (sourced):
  #   source install_mantine.sh
  #   install_mantine /path/to/frontend
  #
  # USO (directo):
  #   bash install_mantine.sh /path/to/frontend

  install_mantine() {
      local project_dir="$1"

      if [ -z "$project_dir" ]; then
          echo "install_mantine: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -f "$project_dir/package.json" ]; then
          echo "install_mantine: no existe package.json en $project_dir" >&2
          return 1
      fi

      # Detectar package manager
      local pm="npm"
      local add_cmd="install"
      local add_dev_flag="--save-dev"
      if [ -f "$project_dir/pnpm-lock.yaml" ] || [ -f "$project_dir/pnpm-workspace.yaml" ]; then
          pm="pnpm"
          add_cmd="add"
          add_dev_flag="-D"
      elif [ -f "$project_dir/yarn.lock" ]; then
          pm="yarn"
          add_cmd="add"
          add_dev_flag="--dev"
      elif [ -f "$project_dir/package-lock.json" ]; then
          pm="npm"
          add_cmd="install"
          add_dev_flag="--save-dev"
      fi

      echo "Detectado package manager: $pm"

      # Dependencias runtime
      local runtime_deps="@mantine/core @mantine/hooks @mantine/charts @mantine/notifications @mantine/form"
      echo "Instalando dependencias Mantine..."
      (cd "$project_dir" && $pm $add_cmd $runtime_deps 2>&1)

      if [ $? -ne 0 ]; then
          echo "install_mantine: fallo instalando dependencias runtime" >&2
          return 1
      fi

      # Dependencias PostCSS (dev)
      local dev_deps="postcss postcss-preset-mantine postcss-simple-vars"
      echo "Instalando dependencias PostCSS..."
      (cd "$project_dir" && $pm $add_cmd $add_dev_flag $dev_deps 2>&1)

      if [ $? -ne 0 ]; then
          echo "install_mantine: fallo instalando dependencias PostCSS" >&2
          return 1
      fi

      # Generar postcss.config.cjs si no existe
      if [ ! -f "$project_dir/postcss.config.cjs" ]; then
          echo "Generando postcss.config.cjs..."
          cat > "$project_dir/postcss.config.cjs" << 'POSTCSS'
  module.exports = {
    plugins: {
      'postcss-preset-mantine': {},
      'postcss-simple-vars': {
        variables: {
          'mantine-breakpoint-xs': '36em',
          'mantine-breakpoint-sm': '48em',
          'mantine-breakpoint-md': '62em',
          'mantine-breakpoint-lg': '75em',
          'mantine-breakpoint-xl': '88em',
        },
      },
    },
  };
  POSTCSS
          echo "postcss.config.cjs creado"
      else
          echo "postcss.config.cjs ya existe, no se sobreescribe"
      fi

      echo "Mantine instalado correctamente en $project_dir"
  }

  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_mantine "$@"
  fi

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto frontend con package.json"}],"output":"sin salida; muestra progreso de instalación"}


id: install_nbconvert_bash_infra
name: install_nbconvert
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_nbconvert(project_dir: string) -> void
tags: ["jupyter","nbconvert","pdf","export","playwright","python","uv"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source install_nbconvert.sh
  install_nbconvert /home/lucas/analysis/finanzas
  ```

tests: []
file_path: bash/functions/infra/install_nbconvert.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: be6ee004a968c0023937c202d2e84019de4b1d71368e03571d611deed2063d3b
description:
  Instala nbconvert y playwright con chromium en un proyecto uv existente. Idempotente: uv add no reinstala si los paquetes ya estan presentes.

notes:
  Requiere que el venv ya exista (usa `init_uv_venv` antes). La instalacion de chromium via `uv run playwright install chromium` puede tardar la primera vez. La salida de playwright se suprime si tiene exito — solo se muestra si hay un error.

code:
  # install_nbconvert
  # ------------------
  # Instala nbconvert y playwright con chromium en un proyecto uv existente.
  # Idempotente: uv add no reinstala si los paquetes ya estan presentes.
  #
  # USO (sourced):
  #   source install_nbconvert.sh
  #   install_nbconvert /path/to/project

  install_nbconvert() {
      local project_dir="$1"

      if [ -z "$project_dir" ]; then
          echo "install_nbconvert: se requiere project_dir" >&2
          return 1
      fi

      if [ ! -d "$project_dir/.venv" ]; then
          echo "install_nbconvert: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
          return 1
      fi

      # Instalar nbconvert y playwright via uv add
      (cd "$project_dir" && uv add nbconvert playwright 2>&1)

      # Instalar chromium — capturar output, solo mostrar si hay error
      local playwright_output
      if ! playwright_output=$(cd "$project_dir" && uv run playwright install chromium 2>&1); then
          echo "$playwright_output" >&2
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"project_dir","desc":"directorio del proyecto con venv existente"}],"output":"sin salida"}


id: install_nodejs_bash_infra
name: install_nodejs
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_nodejs([version: string]) -> void
tags: ["bash","install","nodejs","nvm"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_nodejs.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: f2b757c412590d7ed59ee033dc3c08bf57af4b31466b6baf091659f8ecb18a74
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_nodejs.sh
description:
  Instala Node.js en Linux usando nvm. Instala nvm v0.39.7 si no está presente. Instala la versión de Node indicada, la activa con 'nvm use' y la configura como default. Idempotente si nvm ya está instalado.

example:
  ```bash
  source bash/functions/infra/install_nodejs.sh

  # Instalar Node.js 20 (LTS por defecto)
  install_nodejs

  # Instalar versión específica
  install_nodejs 18
  install_nodejs 21
  ```

notes:
  Requiere `curl`. nvm se instala en `$HOME/.nvm`. Después de instalar en una sesión nueva, hay que recargar el shell para que los comandos `node` y `npm` queden disponibles globalmente.

code:
  #!/usr/bin/env bash
  # install_nodejs
  # --------------
  # Instala Node.js en Linux usando nvm (Node Version Manager).
  # Instala nvm si no está presente, luego instala la versión de Node indicada
  # y la configura como default.
  #
  # USO:
  #   source install_nodejs.sh
  #   install_nodejs [version]
  #
  # ARGUMENTOS:
  #   version   Versión principal de Node.js (default: 20)

  install_nodejs() {
      local node_version="${1:-20}"

      echo "Instalando Node.js v${node_version} mediante nvm..."
      echo ""

      # Informar si Node ya está instalado
      if command -v node &>/dev/null; then
          local current_version
          current_version="$(node --version)"
          echo "Node.js ya está instalado: ${current_version}"
          echo "Continuando con la instalación/actualización..."
      fi

      # Instalar nvm si no está presente
      if [[ -d "$HOME/.nvm" ]]; then
          echo "nvm ya está instalado."
          export NVM_DIR="$HOME/.nvm"
          # shellcheck disable=SC1091
          [[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
      else
          echo "Descargando e instalando nvm..."
          curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

          export NVM_DIR="$HOME/.nvm"
          # shellcheck disable=SC1091
          [[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
          echo "nvm instalado correctamente."
      fi

      # Verificar que nvm esté disponible
      if ! command -v nvm &>/dev/null; then
          echo "install_nodejs: nvm no está disponible después de la instalación" >&2
          echo "  Ejecuta: source ~/.bashrc  (o abre una nueva terminal)" >&2
          return 1
      fi

      echo ""
      echo "Instalando Node.js v${node_version}..."
      nvm install "$node_version"
      nvm use "$node_version"
      nvm alias default "$node_version"

      local installed_node
      local installed_npm
      installed_node="$(node --version)"
      installed_npm="$(npm --version)"

      echo ""
      echo "Node.js instalado correctamente:"
      echo "  Node.js: ${installed_node}"
      echo "  npm:     ${installed_npm}"
      echo ""
      echo "Si es una instalación nueva, reinicia tu terminal o ejecuta:"
      echo "  source ~/.bashrc  # o ~/.zshrc según tu shell"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_nodejs "$@"
  fi

params_schema:
  {"params":[{"name":"version","desc":"versión principal de Node.js a instalar (default: 20)"}],"output":"progreso a stdout con versión instalada; exit code 1 si nvm no queda disponible"}


id: install_nordvpn_bash_infra
name: install_nordvpn
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_nordvpn() -> void
tags: ["vpn","nordvpn","install","infra","wsl2"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_nordvpn.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 57b7aebbc251da1f0d0c4680aef0f2734275a1932a0bfb85c596f41f481f047d
params_schema: {"output":"sin salida; muestra estado de instalación"}
description:
  Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente.

example:
  ```bash
  source install_nordvpn.sh
  install_nordvpn
  # nordvpn ya instalado: NordVPN Version 3.x.x
  # — o —
  # Instalando NordVPN CLI...
  # NordVPN instalado: NordVPN Version 3.x.x
  # NOTA: ejecuta 'nordvpn login' para autenticarte
  ```

notes:
  Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse.

code:
  # install_nordvpn
  # ---------------
  # Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2).
  # Configura el repositorio oficial, instala el paquete y habilita el servicio.
  # Si ya esta instalado, no hace nada.
  #
  # USO (sourced):
  #   source install_nordvpn.sh
  #   install_nordvpn

  install_nordvpn() {
      if command -v nordvpn &>/dev/null; then
          echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)"
          return 0
      fi

      echo "Instalando NordVPN CLI..."

      # Descargar e instalar via script oficial
      sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1

      if ! command -v nordvpn &>/dev/null; then
          echo "install_nordvpn: fallo la instalacion" >&2
          return 1
      fi

      # Agregar usuario al grupo nordvpn para evitar sudo
      sudo usermod -aG nordvpn "$USER" 2>/dev/null || true

      # Habilitar servicio (systemd o manual para WSL2)
      if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then
          sudo systemctl enable --now nordvpnd 2>/dev/null || true
      else
          # WSL2 sin systemd — levantar daemon manualmente
          sudo nordvpnd &>/dev/null &
          sleep 2
      fi

      echo "NordVPN instalado: $(nordvpn version 2>/dev/null)"
      echo "NOTA: ejecuta 'nordvpn login' para autenticarte"
  }


id: install_pnpm_bash_infra
name: install_pnpm
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_pnpm() -> void
tags: ["bash","install","pnpm","node"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source bash/functions/infra/install_pnpm.sh

  install_pnpm
  ```

tests: []
file_path: bash/functions/infra/install_pnpm.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 10e2a6b831fd3764f1ec46842c918788834969cbc02f1de6e008af917d1c06ca
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_pnpm.sh
description:
  Instala pnpm globalmente usando npm (npm install -g pnpm). Verifica que npm esté disponible. Idempotente: si pnpm ya está instalado, informa y termina sin hacer nada.

notes:
  Requiere Node.js/npm instalado previamente. Si la instalación global falla por permisos, usar `sudo npm install -g pnpm` manualmente. Idempotente: vuelve a ejecutarse sin error si pnpm ya existe.

code:
  #!/usr/bin/env bash
  # install_pnpm
  # ------------
  # Instala pnpm globalmente usando npm.
  # Verifica que npm esté disponible antes de instalar.
  # Idempotente: informa si pnpm ya está instalado.
  #
  # USO:
  #   source install_pnpm.sh
  #   install_pnpm

  install_pnpm() {
      echo "Instalando pnpm..."
      echo ""

      # Verificar si pnpm ya está instalado
      if command -v pnpm &>/dev/null; then
          local current_version
          current_version="$(pnpm --version 2>/dev/null || echo "desconocida")"
          echo "pnpm ya está instalado (versión: ${current_version})."
          return 0
      fi

      # Verificar que npm esté disponible
      if ! command -v npm &>/dev/null; then
          echo "install_pnpm: npm no está instalado (requerido para instalar pnpm)" >&2
          echo "  Instala Node.js primero con install_nodejs" >&2
          return 1
      fi

      local npm_version
      npm_version="$(npm --version 2>/dev/null || echo "?")"
      echo "npm detectado: ${npm_version}"
      echo ""

      echo "Instalando pnpm globalmente (npm install -g pnpm)..."
      if ! npm install -g pnpm; then
          echo "install_pnpm: falló la instalación de pnpm" >&2
          echo "  Intenta con sudo: sudo npm install -g pnpm" >&2
          return 1
      fi

      # Verificar instalación
      if ! command -v pnpm &>/dev/null; then
          echo "install_pnpm: pnpm no está disponible después de la instalación" >&2
          echo "  Verifica que npm/bin esté en tu PATH" >&2
          return 1
      fi

      local installed_version
      installed_version="$(pnpm --version)"
      echo ""
      echo "pnpm instalado correctamente: ${installed_version}"
      echo ""
      echo "Comandos útiles:"
      echo "  pnpm install    - Instalar dependencias"
      echo "  pnpm add <pkg>  - Agregar paquete"
      echo "  pnpm run <cmd>  - Ejecutar script"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_pnpm "$@"
  fi

params_schema:
  {"params":[{"name":"(ninguno)","desc":"no acepta argumentos"}],"output":"progreso a stdout con versión instalada; exit code 1 si npm no está disponible o falla la instalación"}


id: install_python312_bash_infra
name: install_python312
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_python312() -> void
tags: ["bash","install","python","python312"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source bash/functions/infra/install_python312.sh

  install_python312
  ```

tests: []
file_path: bash/functions/infra/install_python312.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 149be2695c431448362ecff78849bf5aac7883de742c05ac305993be979b4e4f
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_python312.sh
description:
  Instala Python 3.12 detectando la distribución Linux automáticamente. Ubuntu/Debian/Mint usan deadsnakes PPA; Fedora/RHEL usan dnf; Arch/Manjaro usan pacman. Instala también python3.12-venv, python3.12-dev y verifica pip. Idempotente.

notes:
  Requiere `sudo`. Para distribuciones no soportadas, se recomienda usar pyenv. Idempotente: si `python3.12` ya existe en PATH, informa y termina sin hacer nada.

code:
  #!/usr/bin/env bash
  # install_python312
  # -----------------
  # Instala Python 3.12 en Linux detectando la distribución automáticamente.
  # - Ubuntu/Debian/Pop/Mint/Elementary: usa deadsnakes PPA
  # - Fedora/RHEL/CentOS: usa dnf
  # - Arch/Manjaro: usa pacman
  # Instala también python3.12-venv, python3.12-dev y verifica pip.
  #
  # USO:
  #   source install_python312.sh
  #   install_python312

  install_python312() {
      echo "Instalando Python 3.12..."
      echo ""

      # Detectar distribución
      local distro="unknown"
      if [[ -f /etc/os-release ]]; then
          # shellcheck disable=SC1091
          distro="$(. /etc/os-release && echo "${ID:-unknown}")"
          echo "Distribución detectada: ${distro}"
      else
          echo "install_python312: no se pudo detectar la distribución" >&2
      fi
      echo ""

      # Verificar si Python 3.12 ya está instalado
      if command -v python3.12 &>/dev/null; then
          local current_version
          current_version="$(python3.12 --version 2>&1)"
          echo "Python 3.12 ya está instalado: ${current_version}"
          return 0
      fi

      case "$distro" in
          ubuntu|debian|pop|mint|elementary)
              echo "Instalando Python 3.12 usando deadsnakes PPA..."
              echo ""

              echo "Actualizando repositorios..."
              if ! sudo apt update; then
                  echo "install_python312: falló la actualización de repositorios" >&2
                  return 1
              fi

              echo "Verificando software-properties-common..."
              if ! dpkg -l 2>/dev/null | grep -q software-properties-common; then
                  if ! sudo apt install -y software-properties-common; then
                      echo "install_python312: falló la instalación de software-properties-common" >&2
                      return 1
                  fi
              fi

              echo "Añadiendo deadsnakes PPA..."
              if ! sudo add-apt-repository -y ppa:deadsnakes/ppa; then
                  echo "install_python312: falló al añadir deadsnakes PPA" >&2
                  return 1
              fi

              echo "Actualizando lista de paquetes..."
              if ! sudo apt update; then
                  echo "install_python312: falló la actualización después de añadir PPA" >&2
                  return 1
              fi

              echo "Instalando Python 3.12 y herramientas..."
              if ! sudo apt install -y python3.12 python3.12-venv python3.12-dev python3-pip; then
                  echo "install_python312: falló la instalación de Python 3.12" >&2
                  return 1
              fi
              ;;

          fedora|rhel|centos)
              echo "Instalando Python 3.12 usando dnf..."
              if ! sudo dnf install -y python3.12 python3.12-devel; then
                  echo "install_python312: falló la instalación con dnf" >&2
                  return 1
              fi
              ;;

          arch|manjaro)
              echo "Instalando Python 3.12 usando pacman..."
              if ! sudo pacman -S --noconfirm python; then
                  echo "install_python312: falló la instalación con pacman" >&2
                  return 1
              fi
              ;;

          *)
              echo "install_python312: distribución no soportada automáticamente: ${distro}" >&2
              echo "  Opciones manuales:" >&2
              echo "    - Compilar desde fuente: https://www.python.org/downloads/" >&2
              echo "    - Usar pyenv: curl https://pyenv.run | bash" >&2
              return 1
              ;;
      esac

      echo ""

      # Verificar instalación
      if ! command -v python3.12 &>/dev/null; then
          echo "install_python312: Python 3.12 no está disponible después de la instalación" >&2
          echo "  Puede que necesites reiniciar la terminal" >&2
          return 1
      fi

      local installed_version
      installed_version="$(python3.12 --version 2>&1)"
      echo "Python 3.12 instalado correctamente: ${installed_version}"
      echo ""

      # Verificar pip
      echo "Verificando pip para Python 3.12..."
      if ! python3.12 -m pip --version &>/dev/null; then
          echo "pip no disponible, instalando..."
          if ! python3.12 -m ensurepip --upgrade; then
              echo "  Instala pip manualmente: curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12"
          else
              echo "pip instalado para Python 3.12"
          fi
      else
          echo "pip disponible para Python 3.12"
      fi

      echo ""
      echo "Comandos útiles:"
      echo "  python3.12 -m venv .venv   - Crear entorno virtual"
      echo "  source .venv/bin/activate  - Activar entorno"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_python312 "$@"
  fi

params_schema:
  {"params":[{"name":"(ninguno)","desc":"no acepta argumentos; detecta la distribución automáticamente"}],"output":"progreso a stdout; exit code 1 si la distribución no es soportada o falla la instalación"}


id: install_uv_bash_infra
name: install_uv
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_uv() -> void
tags: ["bash","install","uv","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_uv.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 25abaaa6a196151756cbf10f7cf07a156d79bb4ca392d4a2369b539abb4c19d4
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_uv.sh
description:
  Instala uv, el gestor de paquetes Python ultra-rápido escrito en Rust, usando el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc. Idempotente: si uv ya está instalado, informa y termina.

example:
  ```bash
  source bash/functions/infra/install_uv.sh

  install_uv

  # Uso posterior
  uv venv
  source .venv/bin/activate
  uv pip install requests pandas
  ```

notes:
  Instala en `$HOME/.cargo/bin`. Requiere `curl`. uv es compatible con pip pero 10-100x más rápido. Después de instalar en una sesión nueva, hay que recargar el shell.

code:
  #!/usr/bin/env bash
  # install_uv
  # ----------
  # Instala uv — gestor de paquetes Python ultra-rápido escrito en Rust.
  # Usa el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc.
  #
  # USO:
  #   source install_uv.sh
  #   install_uv

  install_uv() {
      echo "Instalando uv (gestor de paquetes Python)..."
      echo ""

      # Verificar si uv ya está instalado
      if command -v uv &>/dev/null; then
          local current_version
          current_version="$(uv --version 2>/dev/null || echo "desconocida")"
          echo "uv ya está instalado (versión: ${current_version})."
          return 0
      fi

      # Verificar curl
      if ! command -v curl &>/dev/null; then
          echo "install_uv: curl no está instalado (requerido)" >&2
          echo "  Instálalo con: sudo apt install curl" >&2
          return 1
      fi

      echo "Descargando e instalando uv (instalador oficial astral.sh)..."
      if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
          echo "install_uv: falló la instalación de uv" >&2
          echo "  Verifica tu conexión a internet y permisos" >&2
          return 1
      fi

      echo ""

      # Configurar PATH en ~/.bashrc
      if ! grep -q ".cargo/bin" "$HOME/.bashrc" 2>/dev/null; then
          {
              echo ""
              echo "# uv and cargo binaries"
              echo 'export PATH="$HOME/.cargo/bin:$PATH"'
          } >> "$HOME/.bashrc"
          echo "PATH añadido a ~/.bashrc"
      else
          echo "PATH ya configurado en ~/.bashrc"
      fi

      # Configurar PATH en ~/.zshrc si existe
      if [[ -f "$HOME/.zshrc" ]]; then
          if ! grep -q ".cargo/bin" "$HOME/.zshrc" 2>/dev/null; then
              {
                  echo ""
                  echo "# uv and cargo binaries"
                  echo 'export PATH="$HOME/.cargo/bin:$PATH"'
              } >> "$HOME/.zshrc"
              echo "PATH añadido a ~/.zshrc"
          fi
      fi

      # Cargar PATH en la sesión actual
      export PATH="$HOME/.cargo/bin:$PATH"

      echo ""

      # Verificar instalación
      if ! command -v uv &>/dev/null; then
          echo "uv instalado pero no está en el PATH actual."
          echo "  Ejecuta: source ~/.bashrc  (o abre una nueva terminal)"
      else
          local installed_version
          installed_version="$(uv --version)"
          echo "uv instalado correctamente: ${installed_version}"
      fi

      echo ""
      echo "Comandos útiles de uv:"
      echo "  uv venv                     - Crear entorno virtual"
      echo "  uv pip install <package>    - Instalar paquete"
      echo "  uv pip sync requirements.txt - Sincronizar dependencias"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_uv "$@"
  fi

params_schema:
  {"params":[{"name":"(ninguno)","desc":"no acepta argumentos"}],"output":"progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"}


id: install_volta_bash_infra
name: install_volta
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_volta() -> void
tags: ["bash","install","volta","node"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_volta.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: a7d130e96a2526037b588dd8db239a2b13fc7e224e1fa4e8252207d93ccbdcc0
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_volta.sh
description:
  Instala Volta, el gestor de versiones de Node.js, usando el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH en ~/.bashrc y ~/.zshrc. Idempotente: si Volta ya está instalado, informa y termina.

example:
  ```bash
  source bash/functions/infra/install_volta.sh

  install_volta

  # Uso posterior (tras recargar shell)
  volta install node
  volta install pnpm
  volta list
  ```

notes:
  Volta se instala en `$HOME/.volta`. Requiere `curl`. A diferencia de nvm, Volta gestiona versiones de Node.js a nivel de proyecto via `package.json`. Después de instalar, recargar el shell con `source ~/.bashrc`.

code:
  #!/usr/bin/env bash
  # install_volta
  # -------------
  # Instala Volta — gestor de versiones de Node.js rápido y confiable.
  # Usa el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH
  # en ~/.bashrc y ~/.zshrc.
  #
  # USO:
  #   source install_volta.sh
  #   install_volta

  install_volta() {
      echo "Instalando Volta (gestor de versiones Node.js)..."
      echo ""

      # Verificar si Volta ya está instalado
      if command -v volta &>/dev/null; then
          local current_version
          current_version="$(volta --version 2>/dev/null || echo "desconocida")"
          echo "Volta ya está instalado (versión: ${current_version})."
          return 0
      fi

      # Verificar curl
      if ! command -v curl &>/dev/null; then
          echo "install_volta: curl no está instalado (requerido)" >&2
          echo "  Instálalo con: sudo apt install curl" >&2
          return 1
      fi

      echo "Descargando e instalando Volta (instalador oficial)..."
      if ! curl https://get.volta.sh | bash; then
          echo "install_volta: falló la instalación de Volta" >&2
          echo "  Verifica tu conexión a internet" >&2
          return 1
      fi

      echo ""

      # Configurar variables de entorno
      local volta_home="$HOME/.volta"
      export VOLTA_HOME="$volta_home"
      export PATH="$volta_home/bin:$PATH"

      # Configurar en ~/.bashrc
      if ! grep -q "VOLTA_HOME" "$HOME/.bashrc" 2>/dev/null; then
          {
              echo ""
              echo "# Volta configuration"
              echo 'export VOLTA_HOME="$HOME/.volta"'
              echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
          } >> "$HOME/.bashrc"
          echo "Variables añadidas a ~/.bashrc"
      else
          echo "Variables ya configuradas en ~/.bashrc"
      fi

      # Configurar en ~/.zshrc si existe
      if [[ -f "$HOME/.zshrc" ]]; then
          if ! grep -q "VOLTA_HOME" "$HOME/.zshrc" 2>/dev/null; then
              {
                  echo ""
                  echo "# Volta configuration"
                  echo 'export VOLTA_HOME="$HOME/.volta"'
                  echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
              } >> "$HOME/.zshrc"
              echo "Variables añadidas a ~/.zshrc"
          fi
      fi

      echo ""

      # Verificar instalación
      if command -v volta &>/dev/null; then
          local installed_version
          installed_version="$(volta --version)"
          echo "Volta instalado correctamente: ${installed_version}"
      elif [[ -f "$HOME/.volta/bin/volta" ]]; then
          echo "Volta instalado en ${HOME}/.volta/bin pero no está en PATH actual."
          echo "  Ejecuta: source ~/.bashrc  (o abre una nueva terminal)"
      else
          echo "install_volta: Volta no está disponible después de la instalación" >&2
          return 1
      fi

      echo ""
      echo "Próximos pasos:"
      echo "  1. source ~/.bashrc       - Recargar shell"
      echo "  2. volta install node     - Instalar Node.js"
      echo "  3. volta install pnpm     - Instalar pnpm"
      echo ""
      echo "Comandos útiles:"
      echo "  volta install node@20     - Instalar Node.js v20"
      echo "  volta list                - Ver herramientas instaladas"
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_volta "$@"
  fi

params_schema:
  {"params":[{"name":"(ninguno)","desc":"no acepta argumentos"}],"output":"progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"}


id: install_wails_bash_infra
name: install_wails
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: install_wails() -> void
tags: ["bash","install","wails","desktop"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/install_wails.sh
created_at: 2026-04-08T21:29:50Z
updated_at: 2026-04-08T21:29:50Z
props: []
emits: []
variant: []
content_hash: cb6ba3f708b63d37905349b1fba09660cbdd3252e11c6d7da444f26329b93454
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/instaladores/instalar_wails.sh
description:
  Instala Wails v2 (framework de apps de escritorio Go). Detecta la distribución Linux e instala las dependencias de sistema (GTK3, WebKit2GTK, build tools) y luego el CLI via 'go install ...@latest'. Requiere Go instalado previamente.

example:
  ```bash
  source bash/functions/infra/install_wails.sh

  install_wails

  # Verificar
  wails doctor

  # Crear proyecto
  wails init -n my-desktop-app -t react
  ```

notes:
  Requiere Go y sudo. Para distribuciones no listadas (opensuse, etc.) instala las dependencias manualmente y luego procede con el CLI. Templates disponibles: vanilla, vue, react, svelte, lit, angular.

code:
  #!/usr/bin/env bash
  # install_wails
  # -------------
  # Instala Wails v2 — framework para aplicaciones de escritorio en Go.
  # Detecta la distribución Linux e instala las dependencias de sistema necesarias
  # (GTK3, WebKit2GTK, build tools) y luego instala el CLI de Wails via go install.
  #
  # USO:
  #   source install_wails.sh
  #   install_wails

  install_wails() {
      echo "Instalando Wails..."
      echo ""

      # Verificar Go
      if ! command -v go &>/dev/null; then
          echo "install_wails: Go no está instalado (requerido)" >&2
          echo "  Instálalo primero con install_go" >&2
          return 1
      fi

      local go_version
      go_version="$(go version)"
      echo "Go detectado: ${go_version}"
      echo ""

      # Detectar distribución
      local distro="unknown"
      if [[ -f /etc/os-release ]]; then
          # shellcheck disable=SC1091
          distro="$(. /etc/os-release && echo "${ID:-unknown}")"
      else
          echo "install_wails: no se pudo detectar la distribución de Linux" >&2
          return 1
      fi

      echo "Instalando dependencias del sistema para ${distro}..."
      case "$distro" in
          ubuntu|debian|linuxmint|pop)
              sudo apt update
              if ! sudo apt install -y libgtk-3-dev libwebkit2gtk-4.0-dev build-essential pkg-config; then
                  echo "install_wails: falló la instalación de dependencias de sistema" >&2
                  return 1
              fi
              ;;
          fedora|rhel|centos)
              if ! sudo dnf install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkgconfig; then
                  echo "install_wails: falló la instalación de dependencias de sistema" >&2
                  return 1
              fi
              ;;
          arch|manjaro)
              if ! sudo pacman -Sy --noconfirm gtk3 webkit2gtk base-devel; then
                  echo "install_wails: falló la instalación de dependencias de sistema" >&2
                  return 1
              fi
              ;;
          opensuse*)
              if ! sudo zypper install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkg-config; then
                  echo "install_wails: falló la instalación de dependencias de sistema" >&2
                  return 1
              fi
              ;;
          *)
              echo "Distribución no reconocida: ${distro}"
              echo "Instala manualmente: gtk3, webkit2gtk, build-essential, pkg-config"
              echo "Continuando con la instalación de Wails CLI..."
              ;;
      esac

      echo ""
      echo "Instalando Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest)..."
      if ! go install github.com/wailsapp/wails/v2/cmd/wails@latest; then
          echo "install_wails: falló la instalación del CLI de Wails" >&2
          return 1
      fi

      # Asegurar que $GOPATH/bin esté en PATH
      if [[ ":$PATH:" != *":$HOME/go/bin:"* ]]; then
          local shell_config=""
          if [[ -f "$HOME/.bashrc" ]]; then
              shell_config="$HOME/.bashrc"
          elif [[ -f "$HOME/.zshrc" ]]; then
              shell_config="$HOME/.zshrc"
          fi

          if [[ -n "$shell_config" ]]; then
              if ! grep -q 'export PATH=\$PATH:\$HOME/go/bin' "$shell_config" 2>/dev/null; then
                  {
                      echo ""
                      echo "# Go binaries"
                      echo 'export PATH=$PATH:$HOME/go/bin'
                  } >> "$shell_config"
                  echo "PATH de Go añadido a ${shell_config}"
              fi
          fi
          export PATH="$PATH:$HOME/go/bin"
      fi

      echo ""

      # Verificar instalación
      if command -v wails &>/dev/null; then
          local wails_version
          wails_version="$(wails version 2>/dev/null || echo "instalado")"
          echo "Wails instalado correctamente: ${wails_version}"
          echo ""
          echo "Comandos básicos de Wails:"
          echo "  wails init -n myapp -t vanilla  - Crear proyecto"
          echo "  wails dev                        - Modo desarrollo"
          echo "  wails build                      - Build producción"
          echo "  wails doctor                     - Verificar instalación"
      else
          echo "Wails instalado pero no está en PATH."
          echo "  Reinicia tu terminal o ejecuta: source ~/.bashrc"
      fi
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      install_wails "$@"
  fi

params_schema:
  {"params":[{"name":"(ninguno)","desc":"no acepta argumentos; detecta la distribución automáticamente"}],"output":"progreso a stdout; exit code 1 si Go no está disponible, no se detecta la distribución, o falla la instalación"}


id: list_listening_ports_bash_infra
name: list_listening_ports
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: list_listening_ports([mode: string]) -> void
tags: ["bash","ports","network","listening","monitoring"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/list_listening_ports.sh
created_at: 2026-04-08T21:27:54Z
updated_at: 2026-04-08T21:27:54Z
props: []
emits: []
variant: []
content_hash: 07a3347d47334885513dcb44568d4b5ab5c45b5a902c9ef621f6f6b5e65fe916
source_repo: https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git
source_license: MIT
source_file: scripts/linux/gestion_linux/puertos_activos.sh
description:
  Lista puertos activos del sistema usando ss (preferido) o netstat como fallback. Modos: all (LISTEN), tcp, udp, established (conexiones activas), stats (resumen + interfaces). Imprime salida tabulada a stdout.

example:
  ```bash
  source bash/functions/infra/list_listening_ports.sh

  # Todos los puertos en escucha
  list_listening_ports

  # Solo TCP
  list_listening_ports tcp

  # Conexiones establecidas
  list_listening_ports established

  # Estadísticas e interfaces
  list_listening_ports stats
  ```

notes:
  Prefiere `ss` (iproute2) sobre `netstat` (net-tools). El modo `established` limita a 30 filas para no saturar el terminal. No incluye monitor en tiempo real (solo snapshot).

code:
  #!/usr/bin/env bash
  # list_listening_ports
  # --------------------
  # Lista puertos activos en el sistema usando ss o netstat.
  # Soporta filtrado por protocolo y estadísticas de red.
  #
  # USO:
  #   source list_listening_ports.sh
  #   list_listening_ports [mode]
  #
  # ARGUMENTOS:
  #   mode   Modo de listado: all|tcp|udp|established|stats (default: all)

  list_listening_ports() {
      local mode="${1:-all}"

      _has_ss() {
          command -v ss &>/dev/null
      }

      _has_netstat() {
          command -v netstat &>/dev/null
      }

      _require_net_tool() {
          if ! _has_ss && ! _has_netstat; then
              echo "list_listening_ports: no se encontró ss ni netstat en el sistema" >&2
              return 1
          fi
      }

      _llp_all() {
          echo "=== Puertos en escucha (LISTEN) ==="
          if _has_ss; then
              ss -tulnp 2>/dev/null | awk 'NR==1 || /LISTEN/ {print}' | column -t
          elif _has_netstat; then
              netstat -tulnp 2>/dev/null | awk 'NR<=2 || /LISTEN/ {print}' | column -t
          fi
          echo ""
      }

      _llp_tcp() {
          echo "=== Puertos TCP ==="
          if _has_ss; then
              ss -tnlp 2>/dev/null | column -t
          elif _has_netstat; then
              netstat -tnlp 2>/dev/null | column -t
          fi
          echo ""
      }

      _llp_udp() {
          echo "=== Puertos UDP ==="
          if _has_ss; then
              ss -unlp 2>/dev/null | column -t
          elif _has_netstat; then
              netstat -unlp 2>/dev/null | column -t
          fi
          echo ""
      }

      _llp_established() {
          echo "=== Conexiones TCP establecidas ==="
          local count=0
          if _has_ss; then
              ss -tnp 2>/dev/null | awk 'NR==1 || /ESTAB/ {print}' | column -t | head -30
              count="$(ss -tnp 2>/dev/null | grep -c ESTAB || echo 0)"
          elif _has_netstat; then
              netstat -tnp 2>/dev/null | awk 'NR<=2 || /ESTABLISHED/ {print}' | column -t | head -30
              count="$(netstat -tnp 2>/dev/null | grep -c ESTABLISHED || echo 0)"
          fi
          echo ""
          echo "Total de conexiones establecidas: ${count}"
          echo ""
      }

      _llp_stats() {
          echo "=== Estadísticas de red ==="
          if _has_ss; then
              ss -s 2>/dev/null
              echo ""
              echo "=== Interfaces de red ==="
              ip -br addr 2>/dev/null || ifconfig -a 2>/dev/null || echo "No se pudo obtener info de interfaces"
          elif _has_netstat; then
              netstat -s 2>/dev/null | head -50
          fi
          echo ""
      }

      _require_net_tool || return 1

      case "$mode" in
          all)         _llp_all ;;
          tcp)         _llp_tcp ;;
          udp)         _llp_udp ;;
          established) _llp_established ;;
          stats)       _llp_stats ;;
          *)
              echo "list_listening_ports: modo desconocido '${mode}'. Usa: all|tcp|udp|established|stats" >&2
              return 1
              ;;
      esac
  }

  # Ejecutar si se invoca directamente
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
      list_listening_ports "$@"
  fi

params_schema:
  {"params":[{"name":"mode","desc":"qué listar: all|tcp|udp|established|stats (default: all)"}],"output":"tabla de puertos/conexiones a stdout; exit code 1 si no hay ss ni netstat, o si el modo es desconocido"}


id: nordvpn_connect_bash_infra
name: nordvpn_connect
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_connect(country?: string, city?: string) -> json
tags: ["vpn","nordvpn","connect","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: bash/functions/infra/nordvpn_connect.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: ccdb98fc2ce2c69f41b1dcaaaac86d601b147ace796f93091c8bba9d47dd93e4
description:
  Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado.

example:
  ```bash
  source nordvpn_connect.sh

  nordvpn_connect
  # {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"}

  nordvpn_connect Spain
  # {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"}

  nordvpn_connect Spain Madrid
  # {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"}
  ```

notes:
  Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino.

code:
  # nordvpn_connect
  # ---------------
  # Conecta a NordVPN. Acepta pais, ciudad o servidor especifico.
  # Sin argumentos conecta al mejor servidor disponible.
  # Imprime JSON con el resultado de la conexion.
  #
  # USO (sourced):
  #   source nordvpn_connect.sh
  #   nordvpn_connect                    # mejor servidor
  #   nordvpn_connect Spain              # por pais
  #   nordvpn_connect Spain Madrid       # por ciudad
  #   nordvpn_connect Spain '#42'        # servidor especifico

  nordvpn_connect() {
      local country="${1:-}"
      local city="${2:-}"

      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local args=()
      [ -n "$country" ] && args+=("$country")
      [ -n "$city" ] && args+=("$city")

      local output
      output=$(nordvpn connect "${args[@]}" 2>&1)
      local rc=$?

      if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then
          local server
          server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1)
          echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}"
      else
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi
  }

params_schema:
  {"params":[{"name":"country","desc":"país de destino (opcional; default: auto)"},{"name":"city","desc":"ciudad de destino (opcional; default: auto)"}],"output":"JSON con ok, server, country, city"}


id: nordvpn_disconnect_bash_infra
name: nordvpn_disconnect
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: nordvpn_disconnect() -> json
tags: ["vpn","nordvpn","disconnect","infra","network"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
example:
  ```bash
  source nordvpn_disconnect.sh

  nordvpn_disconnect
  # {"ok":true,"status":"disconnected"}
  ```

tests: []
file_path: bash/functions/infra/nordvpn_disconnect.sh
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
notes: Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado.
content_hash: ba29b2977ffedf5c85c1264d693348eb21aeefa38636ff85c815afb7e5acafc8
params_schema: {"output":"JSON con ok y status"}
description:
  Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado.

code:
  # nordvpn_disconnect
  # ------------------
  # Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok.
  # Imprime JSON con el resultado.
  #
  # USO (sourced):
  #   source nordvpn_disconnect.sh
  #   nordvpn_disconnect

  nordvpn_disconnect() {
      if ! command -v nordvpn &>/dev/null; then
          echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
          return 1
      fi

      local output
      output=$(nordvpn disconnect 2>&1)
      local rc=$?

      if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then
          echo '{"ok":true,"status":"disconnected"}'
      else
          echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
          return 1
      fi
  }


