feat(doctor): add fn doctor CLI + 14 functions for system management

Adds `fn doctor` read-only diagnostic command with subcommands artefacts,
services, sync, uses-functions, unused, and --json flag for agents.
Each subcommand wraps a registry function in functions/infra/.

New functions:
- artefact_doctor, services_status, pc_locations_drift,
  audit_uses_functions, find_unused_functions (Go diagnostics)
- backup_sqlite_db, rotate_backups, wait_for_http, wait_for_port,
  port_kill, tail_journal, pre_commit_hook_install (bash utilities)
- notify_telegram (Go HTTP)
- backup_all pipeline (tag launcher)

Plus prior session leftovers (scan_secrets_in_dirty, append_diary_entry,
git utilities, http_session_cookie_middleware, compile/full-git pipelines).

Fixes pc_locations_drift filepath.Join bug with absolute dir_path.
Documents fn doctor in CLAUDE.md, .claude/rules/fn_doctor.md (rule 23),
docs/architecture.md, CHANGELOG.md (2026-05-07), and diary entry.

First fn doctor uses-functions run found drift in 7/12 apps (deuda
para sincronizar app.md con imports reales).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:42:10 +02:00
parent fd9e9135a9
commit 2a3d780347
77 changed files with 6511 additions and 534 deletions
@@ -0,0 +1,56 @@
---
name: scan_secrets_in_dirty
kind: function
lang: bash
domain: cybersecurity
version: "1.0.0"
purity: impure
signature: "scan_secrets_in_dirty(repo_dir: string) -> stdout: matched paths"
description: "Para un repo git, lista archivos modificados/nuevos cuyo nombre matchee patron de secret. Patrones: .env, credentials, .key, .pem, id_rsa, secret, token*.txt. Stdout vacio si no hay matches. Exit 0 siempre que el repo exista."
tags: [git, secrets, security, scan, credentials, cybersecurity]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: repo_dir
desc: "path al repo git a escanear; default '.'"
output: "paths sospechosos por stdout (uno por linea), vacio si todo limpio; exit 1 solo si repo_dir no es un repo git"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/cybersecurity/scan_secrets_in_dirty.sh"
---
## Ejemplo
```bash
source bash/functions/cybersecurity/scan_secrets_in_dirty.sh
# Escanear repo actual
matches=$(scan_secrets_in_dirty .)
if [[ -n "$matches" ]]; then
echo "ABORTAR: archivos sospechosos detectados:"
echo "$matches"
exit 1
fi
# Escanear repo especifico
scan_secrets_in_dirty /home/lucas/fn_registry
```
## Patrones detectados
- `.env`, `.env.local`, `.env.production`, etc.
- `*credentials*`
- `*.key`
- `*.pem`
- `id_rsa*`
- `*secret*`
- `*token*.txt`
## Notas
Usa `git status --porcelain` para listar solo archivos del working tree (modificados, nuevos, staged). No escanea el contenido del archivo, solo el nombre. Las claves GPG cifradas (`.gpg`) no se detectan intencionalmente — son opacas. Exit 0 siempre que el directorio sea un repo git valido, incluso si no hay matches.
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# scan_secrets_in_dirty — Para un repo git, lista archivos modificados/nuevos
# cuyo nombre matchee patron de secret. Stdout vacio si no hay matches.
# Exit 0 siempre que el repo exista (el caller decide si abortar).
scan_secrets_in_dirty() {
local repo_dir="${1:-.}"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "scan_secrets_in_dirty: '$repo_dir' no es un repo git" >&2
return 1
fi
# Listar archivos modificados o nuevos (excluyendo borrados)
# y filtrar por patron de secret en el nombre del archivo
git -C "$repo_dir" status --porcelain \
| awk '{print $NF}' \
| grep -E '(^|/)(\.env(\..*)?$|.*credentials.*|.*\.key$|.*\.pem$|id_rsa.*|.*secret.*|.*token.*\.txt$)' \
|| true
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
scan_secrets_in_dirty "$@"
fi
@@ -0,0 +1,67 @@
---
name: append_diary_entry
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "append_diary_entry(titulo: string, cuerpo: string) -> string"
description: "Añade una entrada al diario del dia en ${DIARY_DIR:-docs/diary}/YYYY-MM-DD.md. Crea el archivo con cabecera si no existe. Nunca reescribe contenido previo. Si cuerpo es vacio escribe solo el header de la seccion."
tags: [diary, markdown, append, idempotent, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: titulo
desc: "Titulo corto de la entrada (obligatorio). Se usa como cabecera H2 junto a la hora actual. Exit 2 si viene vacio."
- name: cuerpo
desc: "Cuerpo markdown de la entrada (opcional). Si se omite o es vacio, se escribe solo la linea de cabecera H2 sin parrafo adicional — util para marcar un momento sin detalle."
output: "Path relativo al archivo de diario escrito (ej: docs/diary/2026-05-07.md). Varia segun DIARY_DIR y la fecha del sistema."
tested: true
tests:
- "crear archivo nuevo con titulo y cuerpo"
- "append mismo dia conserva primera entrada"
- "entrada sin cuerpo escribe header"
test_file_path: "bash/functions/infra/append_diary_entry.sh"
file_path: "bash/functions/infra/append_diary_entry.sh"
---
## Ejemplo
```bash
source bash/functions/infra/append_diary_entry.sh
# Entrada con titulo y cuerpo
path=$(append_diary_entry "cerrado issue #42" "- Fix en http_client.cpp\n- Tests pasando")
echo "Escrito en: $path"
# Entrada sin cuerpo (marcar momento)
append_diary_entry "inicio sesion tarde"
# Usar directorio personalizado
DIARY_DIR=/home/lucas/personal/diary append_diary_entry "nota personal" "Reunion a las 18h."
```
## Formato del bloque escrito
```markdown
# 2026-05-07
## 14:32 — cerrado issue #42
- Fix en http_client.cpp
- Tests pasando
## 17:05 — inicio sesion tarde
```
## Notas
- Respeta la variable de entorno `DIARY_DIR` para reusar fuera del registry.
- `mkdir -p` garantiza que el directorio se crea si no existe.
- El bloque siempre empieza con una linea en blanco para separar entradas anteriores.
- Los tests viven en el mismo `.sh` bajo el guard `[ "$1" = "--test" ]`. Ejecutar con `bash bash/functions/infra/append_diary_entry.sh --test`.
- `error_type: error_go_core` sigue la convencion del registry para funciones bash impuras aunque bash use exit codes nativos, no el tipo Go.
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# append_diary_entry — Añade una entrada al diario del dia en docs/diary/YYYY-MM-DD.md
append_diary_entry() {
local titulo="$1"
local cuerpo="${2:-}"
if [[ -z "$titulo" ]]; then
echo "append_diary_entry: titulo requerido" >&2
return 2
fi
local date time diary_dir diary_file
date=$(date +%Y-%m-%d)
time=$(date +%H:%M)
diary_dir="${DIARY_DIR:-docs/diary}"
diary_file="${diary_dir}/${date}.md"
# Crear directorio y cabecera del dia si el archivo no existe
if [[ ! -f "$diary_file" ]]; then
mkdir -p "$diary_dir"
printf '# %s\n' "$date" > "$diary_file"
fi
# Componer bloque de entrada
if [[ -n "$cuerpo" ]]; then
printf '\n## %s — %s\n\n%s\n' "$time" "$titulo" "$cuerpo" >> "$diary_file"
else
printf '\n## %s — %s\n' "$time" "$titulo" >> "$diary_file"
fi
echo "$diary_file"
}
# ---------------------------------------------------------------------------
# Self-test: bash bash/functions/infra/append_diary_entry.sh --test
# ---------------------------------------------------------------------------
if [ "${BASH_SOURCE[0]}" = "$0" ] && [ "${1:-}" = "--test" ]; then
set -euo pipefail
declare -i PASS=0 FAIL=0
assert_contains() {
local test_name="$1" pattern="$2" file="$3"
if grep -qF "$pattern" "$file"; then
echo "PASS: $test_name"
PASS+=1
else
echo "FAIL: $test_name — patron '$pattern' no encontrado en $file"
FAIL+=1
fi
}
# Setup: directorio temporal
tmpdir=$(mktemp -d)
export DIARY_DIR="$tmpdir/diary"
# Test 1: crear archivo nuevo con titulo y cuerpo
diary_file=$(append_diary_entry "primer bloque" "Hecho algo importante hoy.")
assert_contains "crear archivo nuevo con titulo y cuerpo" "primer bloque" "$diary_file"
assert_contains "crear archivo nuevo con titulo y cuerpo" "# $(date +%Y-%m-%d)" "$diary_file"
assert_contains "crear archivo nuevo con titulo y cuerpo" "Hecho algo importante hoy." "$diary_file"
# Test 2: append en el mismo dia sin reescribir contenido previo
append_diary_entry "segundo bloque" "Otro parrafo de trabajo." > /dev/null
assert_contains "append mismo dia conserva primera entrada" "primer bloque" "$diary_file"
assert_contains "append mismo dia anade segunda entrada" "segundo bloque" "$diary_file"
# Test 3: entrada sin cuerpo (solo header)
append_diary_entry "solo titulo sin cuerpo" "" > /dev/null
assert_contains "entrada sin cuerpo escribe header" "solo titulo sin cuerpo" "$diary_file"
# Cleanup
rm -rf "$tmpdir"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
fi
+55
View File
@@ -0,0 +1,55 @@
---
name: backup_sqlite_db
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "backup_sqlite_db <source> <dest>"
description: "Snapshot atomico de una BD SQLite usando VACUUM INTO. Mas seguro que cp: no corrompe si hay escrituras concurrentes. Crea el directorio destino si no existe. Si dest ya existe, lo sobrescribe."
tags: [backup, sqlite, vacuum, snapshot, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: source
desc: "Ruta absoluta o relativa a la BD SQLite fuente. Debe existir y ser un archivo SQLite valido."
- name: dest
desc: "Ruta absoluta o relativa del snapshot destino. Se crea (o sobreescribe) con VACUUM INTO."
output: "Imprime 'OK <bytes> bytes -> <dest>' a stdout en caso de exito. Los errores van a stderr."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/backup_sqlite_db.sh"
---
## Ejemplo
```bash
source bash/functions/infra/backup_sqlite_db.sh
# Backup de registry.db en directorio backups/
backup_sqlite_db registry.db backups/registry_$(date +%Y%m%d_%H%M%S).db
# OK 2457600 bytes -> backups/registry_20260507_103045.db
# Backup con rutas absolutas
backup_sqlite_db /opt/apps/myapp/myapp.db /mnt/backups/myapp.db
```
## Exit codes
| Codigo | Significado |
|--------|-------------|
| 0 | Exito |
| 1 | Source no existe |
| 2 | Source no es SQLite valido |
| 3 | Fallo en VACUUM INTO o creacion de directorio |
| 4 | Dest tiene tamaño 0 tras el backup |
| 5 | sqlite3 CLI no encontrado en PATH |
## Notas
`VACUUM INTO` (disponible desde SQLite 3.27.0) ejecuta un vacuum completo y escribe la BD resultado en un nuevo archivo. Es atomico: si falla a mitad, el destino no queda en estado inconsistente. Esto lo hace superior a un `cp` simple cuando la BD puede estar recibiendo escrituras concurrentes (WAL mode, journal). El archivo resultante es siempre una BD limpia y compactada.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# backup_sqlite_db — Snapshot atomico de una BD SQLite usando VACUUM INTO.
# Mas seguro que cp: no corrompe si hay escrituras concurrentes.
backup_sqlite_db() {
local source="$1"
local dest="$2"
# Verificar dependencia sqlite3
if ! command -v sqlite3 &>/dev/null; then
echo "backup_sqlite_db: sqlite3 no encontrado en PATH" >&2
return 5
fi
# Verificar que source existe
if [[ ! -f "$source" ]]; then
echo "backup_sqlite_db: source no existe: $source" >&2
return 1
fi
# Verificar que source es SQLite valido (header magico)
local header
header=$(head -c 16 "$source" 2>/dev/null | strings | head -n1)
if [[ "$header" != "SQLite format 3" ]]; then
echo "backup_sqlite_db: source no es una BD SQLite valida: $source" >&2
return 2
fi
# Crear directorio destino si no existe
local dest_dir
dest_dir=$(dirname "$dest")
if [[ ! -d "$dest_dir" ]]; then
mkdir -p "$dest_dir" || {
echo "backup_sqlite_db: no se pudo crear directorio: $dest_dir" >&2
return 3
}
fi
# Si el destino existe, borrarlo para que VACUUM INTO no falle
if [[ -f "$dest" ]]; then
rm -f "$dest"
fi
# Ejecutar VACUUM INTO (escape de comillas simples en el path)
local escaped_dest="${dest//\'/\'\'}"
if ! sqlite3 "$source" "VACUUM INTO '${escaped_dest}';" 2>/dev/null; then
echo "backup_sqlite_db: fallo en VACUUM INTO: source=$source dest=$dest" >&2
return 3
fi
# Verificar que dest existe y tiene tamaño > 0
if [[ ! -f "$dest" ]]; then
echo "backup_sqlite_db: dest no fue creado: $dest" >&2
return 3
fi
local bytes
bytes=$(wc -c < "$dest" 2>/dev/null)
if [[ -z "$bytes" || "$bytes" -eq 0 ]]; then
echo "backup_sqlite_db: dest tiene tamaño 0: $dest" >&2
return 4
fi
echo "OK ${bytes} bytes -> ${dest}"
return 0
}
@@ -0,0 +1,65 @@
---
name: deploy_cpp_exe_to_windows
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "deploy_cpp_exe_to_windows(app_name: string, app_dir: string) -> void"
description: "Copia el .exe de Windows (compilado por build_cpp_windows) y sus assets al escritorio de Windows /mnt/c/Users/lucas/Desktop/apps/<APP>/. Mata el proceso si esta corriendo (taskkill.exe pre-autorizado), copia DLLs, sincroniza assets/ y enrichers/ con rsync, maneja runtime Python embebido si python_runtime: true en app.md, y copia extras gx-cli. Preserva siempre local_files/ (estado del usuario)."
tags: [cpp, deploy, windows, exe, dll, assets, rsync]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/deploy_cpp_exe_to_windows.sh"
params:
- name: app_name
desc: "Nombre de la app (ej: chart_demo). Se usa para localizar cpp/build/windows/apps/<app>/<app>.exe y el directorio destino Desktop/apps/<app>/."
- name: app_dir
desc: "Ruta absoluta al directorio fuente de la app (ej: /home/lucas/fn_registry/cpp/apps/chart_demo). Se usa para localizar enrichers/, runtime/ y app.md."
output: "Copia archivos al escritorio de Windows. Imprime 'OK: <app> -> <dest>' en stdout. Si local_files/ existe, imprime su tamanio. Errores fatales a stderr con exit 1."
---
## Ejemplo
```bash
deploy_cpp_exe_to_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo"
# OK: chart_demo -> /mnt/c/Users/lucas/Desktop/apps/chart_demo
# Con rutas custom via env vars
BUILD_WIN=/custom/build deploy_cpp_exe_to_windows "myapp" "/path/to/myapp"
```
## Layout destino
```
Desktop/apps/<APP>/
├── <APP>.exe # binario (top level, convencion DLL Windows)
├── *.dll # DLLs nativas junto al exe
├── assets/
│ ├── *.ttf # fuentes de add_imgui_app
│ ├── enrichers/ # si <app_dir>/enrichers/ existe
│ ├── runtime/ # Python embed si python_runtime: true en app.md
│ ├── gx-cli # si existe en app_dir/
│ └── gx-cli.exe # si existe en app_dir/
└── local_files/ # NUNCA tocado (estado del usuario: DBs, settings, layouts)
```
## Variables de entorno
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
## Notas
- `taskkill.exe /IM <app>.exe /F` pre-autorizado por el usuario (sin pedir confirmacion).
- `rsync --delete` en assets/ y enrichers/ para mantener destino limpio.
- Si `python_runtime: true` en `app.md` y `runtime/.lock` es mas antiguo que `app.md`, invoca `tools/freeze_python_runtime.sh` automaticamente.
- `local_files/` jamas se toca: contiene estado per-PC del usuario (DBs SQLite, ImGui layouts, settings).
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# deploy_cpp_exe_to_windows — Copia el .exe compilado (y DLLs, assets, enrichers, runtime)
# desde cpp/build/windows/ al escritorio de Windows /mnt/c/Users/lucas/Desktop/apps/<APP>/.
# Preserva local_files/ (estado del usuario). Pre-authorized: taskkill.exe /F para matar proceso.
deploy_cpp_exe_to_windows() {
local app="${1:-}"
local app_dir="${2:-}"
if [ -z "$app" ] || [ -z "$app_dir" ]; then
echo "ERROR: uso: deploy_cpp_exe_to_windows <app_name> <app_dir>" >&2
return 1
fi
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
# --- 1. Verificar que el .exe existe ---
local exe_src="$build_win/apps/$app/$app.exe"
if [ ! -f "$exe_src" ]; then
echo "ERROR: no se ha generado $exe_src" >&2
echo "Compila primero con: build_cpp_windows $app" >&2
return 1
fi
# --- 2. Crear directorios destino ---
local dest="$win_desktop_apps/$app"
local assets="$dest/assets"
mkdir -p "$dest" "$assets"
# --- 3. Pre-deploy: matar proceso si esta corriendo en Windows ---
if command -v taskkill.exe >/dev/null 2>&1; then
taskkill.exe /IM "${app}.exe" /F >/dev/null 2>&1 || true
fi
# --- 4. Copiar .exe al top level ---
cp -v "$exe_src" "$dest/"
# --- 5. DLLs al top level (Windows DLL search convention) ---
find "$build_win/apps/$app" -maxdepth 1 -type f -name '*.dll' \
-exec cp -v {} "$dest/" \;
# --- 6. assets/ del build (TTFs, etc.) -> dest/assets/ ---
if [ -d "$build_win/apps/$app/assets" ]; then
rsync -a --delete "$build_win/apps/$app/assets/" "$assets/"
fi
# --- 7. enrichers/ del app_dir -> assets/enrichers/ ---
if [ -d "$app_dir/enrichers" ]; then
rsync -a --delete \
--exclude '__pycache__' \
--exclude '*.pyc' \
"$app_dir/enrichers/" "$assets/enrichers/"
fi
# --- 8. runtime/ Python embebido -> assets/runtime/ ---
if grep -q '^python_runtime:[[:space:]]*true' "$app_dir/app.md" 2>/dev/null; then
if [ ! -d "$app_dir/runtime/python" ] || \
[ "$app_dir/app.md" -nt "$app_dir/runtime/.lock" ]; then
echo "[freeze] regenerando runtime Python (Windows) para $app"
"$app_dir/tools/freeze_python_runtime.sh" "$app_dir" windows
fi
rsync -a --delete \
--exclude '__pycache__' \
--exclude '*.pyc' \
"$app_dir/runtime/" "$assets/runtime/"
fi
# --- 9. Extras: gx-cli, gx-cli.exe -> assets/ ---
for extra in gx-cli gx-cli.exe; do
if [ -f "$app_dir/$extra" ]; then
cp -v "$app_dir/$extra" "$assets/"
fi
done
# --- 10. NO tocar local_files/ (estado del usuario) ---
echo ""
echo "OK: $app -> $dest"
if [ -d "$dest/local_files" ]; then
echo " local_files/ preservado: $(du -sh "$dest/local_files" | cut -f1)"
fi
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
deploy_cpp_exe_to_windows "$@"
fi
@@ -0,0 +1,46 @@
---
name: discover_git_repos
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "discover_git_repos(root_dir: string) -> stdout: newline-separated paths"
description: "Encuentra todos los repos git dentro de root_dir. Devuelve paths absolutos (sin /.git) en stdout, uno por linea, ordenados. Incluye el propio root_dir si tiene .git. Excluye node_modules, .venv, cpp/vendor, cpp/build, sources, temp, subrepos e interior de .git."
tags: [git, repo, discover, find, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: root_dir
desc: "directorio raiz a escanear (absoluto o relativo); default '.'"
output: "paths absolutos newline-separated por stdout, uno por linea, ordenados alfabeticamente; el propio root_dir aparece primero si tiene .git"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/discover_git_repos.sh"
---
## Ejemplo
```bash
source bash/functions/infra/discover_git_repos.sh
# Listar todos los repos bajo fn_registry
discover_git_repos /home/lucas/fn_registry
# Contar repos
discover_git_repos /home/lucas/fn_registry | wc -l
# Iterar
while IFS= read -r repo; do
echo "Repo: $repo"
done < <(discover_git_repos /home/lucas/fn_registry)
```
## Notas
Usa `find` con `-mindepth 2` para subdirectorios (el root se comprueba por separado). Los resultados se emiten ordenados alfabeticamente. La funcion es impura porque lee el sistema de archivos. Sale con exit code 1 si `root_dir` no existe.
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# discover_git_repos — Encuentra todos los repos git dentro de root_dir.
# Devuelve paths relativos (sin /.git) en stdout, uno por linea, ordenados.
# Incluye el propio root_dir si tiene .git. Excluye node_modules, .venv,
# cpp/vendor, cpp/build, sources, temp, subrepos e interior de .git.
discover_git_repos() {
local root_dir="${1:-.}"
if [[ ! -d "$root_dir" ]]; then
echo "discover_git_repos: directorio '$root_dir' no existe" >&2
return 1
fi
# Normalizar: convertir a path absoluto para calculos consistentes
local abs_root
abs_root="$(cd "$root_dir" && pwd)"
# Incluir el propio root si tiene .git
local results=()
if [[ -d "$abs_root/.git" ]]; then
results+=("$abs_root")
fi
# Buscar .git/ en subdirectorios, con exclusiones
while IFS= read -r git_dir; do
local repo_dir="${git_dir%/.git}"
results+=("$repo_dir")
done < <(find "$abs_root" -mindepth 2 -name ".git" -type d \
-not -path "*/.git/*" \
-not -path "*/node_modules/*" \
-not -path "*/.venv/*" \
-not -path "*/cpp/vendor/*" \
-not -path "*/cpp/build/*" \
-not -path "*/sources/*" \
-not -path "*/temp/*" \
-not -path "*/subrepos/*" \
2>/dev/null | sort)
# Imprimir resultados ordenados (uno por linea)
printf '%s\n' "${results[@]}" | sort
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
discover_git_repos "$@"
fi
@@ -0,0 +1,61 @@
---
name: git_auto_commit_dirty
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "git_auto_commit_dirty(repo_dir: string, message?: string) -> stdout: commit subject or empty"
description: "Si el repo tiene cambios sin commitear, hace git add -A y git commit. Genera mensaje automatico si no se pasa: detecta patron de dominio (python/functions/<dom>/, dev/issues/, functions/<dom>/, bash/functions/<dom>/) o usa chore: auto-commit con lista de paths. Anade Co-Authored-By Claude. Salta si el repo es ~/.password-store."
tags: [git, commit, auto, infra, dirty]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: repo_dir
desc: "path al repo git donde commitear; default '.'"
- name: message
desc: "mensaje de commit fijo (opcional); si se omite, se genera automaticamente segun los archivos cambiados"
output: "subject del commit creado por stdout (primera linea del mensaje); vacio si no habia cambios o si el repo es password-store"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/git_auto_commit_dirty.sh"
---
## Ejemplo
```bash
source bash/functions/infra/git_auto_commit_dirty.sh
# Commitear con mensaje automatico
subject=$(git_auto_commit_dirty /home/lucas/fn_registry)
echo "Commit: $subject"
# Commitear con mensaje fijo
git_auto_commit_dirty /home/lucas/myapp "feat: add new feature"
# Revisar si se creo commit (subject no vacio = commit creado)
if [[ -n "$subject" ]]; then
echo "Commit creado: $subject"
else
echo "Sin cambios"
fi
```
## Logica de mensaje automatico
| Patron | Mensaje generado |
|---|---|
| Todos los paths bajo `python/functions/<dom>/` | `feat(<dom>): auto-commit con N cambios` |
| Todos los paths bajo `dev/issues/` | `chore(issues): auto-commit` |
| Todos los paths bajo `functions/<dom>/` | `feat(<dom>): auto-commit con N cambios` |
| Todos los paths bajo `bash/functions/<dom>/` | `feat(<dom>): auto-commit con N cambios` |
| Paths dispersos | `chore: auto-commit (N archivos)` + lista |
## Notas
El Co-Authored-By se anade siempre como segundo `-m` del commit. El repo `~/.password-store` (o `$PASSWORD_STORE_DIR`) se salta silenciosamente — `pass` gestiona sus propios commits. Exit 1 solo si el repo no existe o si git commit falla.
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# git_auto_commit_dirty — Si el repo tiene cambios sin commitear, hace git add -A
# y git commit con mensaje generado o el que se pase como argumento.
# Stdout: subject del commit creado, vacio si no habia cambios.
# Salta sin commitear si el repo es ~/.password-store (pass gestiona sus propios commits).
git_auto_commit_dirty() {
local repo_dir="${1:-.}"
local message="${2:-}"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "git_auto_commit_dirty: '$repo_dir' no es un repo git" >&2
return 1
fi
# Normalizar path
local abs_repo
abs_repo="$(cd "$repo_dir" && pwd)"
# Saltear ~/.password-store — pass gestiona sus propios commits
local pass_dir
pass_dir="${PASSWORD_STORE_DIR:-$HOME/.password-store}"
pass_dir="$(cd "$pass_dir" 2>/dev/null && pwd)" || true
if [[ -n "$pass_dir" && "$abs_repo" == "$pass_dir"* ]]; then
# No commitear — pass se autocommitea
return 0
fi
# Comprobar si hay cambios
local status
status=$(git -C "$abs_repo" status --porcelain)
if [[ -z "$status" ]]; then
# Nada que commitear
return 0
fi
# Generar mensaje automatico si no se proporciono
if [[ -z "$message" ]]; then
message="$(_git_auto_commit_dirty_generate_message "$abs_repo" "$status")"
fi
# Commitear
git -C "$abs_repo" add -A
local commit_output
commit_output=$(git -C "$abs_repo" commit \
-m "$message" \
-m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" \
2>&1)
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "git_auto_commit_dirty: fallo al commitear en '$abs_repo'" >&2
echo "$commit_output" >&2
return 1
fi
# Imprimir subject del commit
echo "$message" | head -1
}
# Funcion auxiliar: genera mensaje de commit automatico
_git_auto_commit_dirty_generate_message() {
local repo_dir="$1"
local status="$2"
# Contar tipos de cambios
local n_modified n_added n_deleted
n_modified=$(echo "$status" | grep -c '^ M\| M\|MM\|AM\|CM\|RM' 2>/dev/null || true)
n_added=$(echo "$status" | grep -c '^??\|^A ' 2>/dev/null || true)
n_deleted=$(echo "$status" | grep -c '^ D\|^D ' 2>/dev/null || true)
# Extraer paths (sin el prefijo de status)
local paths
paths=$(echo "$status" | awk '{print $NF}')
# Detectar patron comun: todos bajo python/functions/<dom>/
local py_dom
py_dom=$(echo "$paths" | grep -oE '^python/functions/[^/]+' | sort -u)
if [[ $(echo "$py_dom" | wc -l) -eq 1 && -n "$py_dom" ]]; then
local dom
dom=$(echo "$py_dom" | sed 's|python/functions/||')
local n_total
n_total=$(echo "$paths" | wc -l)
echo "feat($dom): auto-commit con $n_total cambios"
return
fi
# Detectar patron: todos bajo dev/issues/
if echo "$paths" | grep -q '^dev/issues/' && ! echo "$paths" | grep -qv '^dev/issues/'; then
echo "chore(issues): auto-commit"
return
fi
# Detectar patron: todos bajo functions/<dom>/ (Go)
local go_dom
go_dom=$(echo "$paths" | grep -oE '^functions/[^/]+' | sort -u)
if [[ $(echo "$go_dom" | wc -l) -eq 1 && -n "$go_dom" ]]; then
local dom
dom=$(echo "$go_dom" | sed 's|functions/||')
local n_total
n_total=$(echo "$paths" | wc -l)
echo "feat($dom): auto-commit con $n_total cambios"
return
fi
# Detectar patron: todos bajo bash/functions/<dom>/
local bash_dom
bash_dom=$(echo "$paths" | grep -oE '^bash/functions/[^/]+' | sort -u)
if [[ $(echo "$bash_dom" | wc -l) -eq 1 && -n "$bash_dom" ]]; then
local dom
dom=$(echo "$bash_dom" | sed 's|bash/functions/||')
local n_total
n_total=$(echo "$paths" | wc -l)
echo "feat($dom): auto-commit con $n_total cambios"
return
fi
# Caso general: cambios dispersos
local n_total
n_total=$(echo "$paths" | wc -l)
local subject="chore: auto-commit ($n_total archivos)"
# Listar hasta 10 paths en el body (se anade como segundo -m)
local body
body=$(echo "$paths" | head -10 | awk '{print "- "$0}')
if [[ $(echo "$paths" | wc -l) -gt 10 ]]; then
body="$body"$'\n'"- ..."
fi
echo "$subject"$'\n\n'"$body"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
git_auto_commit_dirty "$@"
fi
@@ -0,0 +1,68 @@
---
name: git_pull_with_stash
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "git_pull_with_stash(repo_dir: string) -> stdout: status"
description: "Si el repo tiene cambios pendientes, los stashea antes de pullear. Hace fetch origin + pull --ff-only. Si hay divergencia reporta [diverged] y restaura el stash. Si stash pop da conflicto reporta [stash-conflict] sin tocarlo. Exit 0 siempre para que el caller pueda continuar con otros repos."
tags: [git, pull, stash, infra, sync]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: repo_dir
desc: "path al repo git donde pullear; default '.'"
output: "linea de estado por stdout: '[pulled] repo', '[up-to-date] repo', '[diverged] repo' o '[stash-conflict] repo'"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/git_pull_with_stash.sh"
---
## Ejemplo
```bash
source bash/functions/infra/git_pull_with_stash.sh
# Pullear repo con auto-stash
status=$(git_pull_with_stash /home/lucas/fn_registry)
echo "$status"
# [pulled] fn_registry
# o:
# [up-to-date] fn_registry
# o:
# [diverged] fn_registry (pull fallo por divergencia)
# Iterar y coleccionar divergencias
diverged=()
while IFS= read -r repo; do
result=$(git_pull_with_stash "$repo")
echo "$result"
if [[ "$result" == "[diverged]"* || "$result" == "[stash-conflict]"* ]]; then
diverged+=("$result")
fi
done < <(discover_git_repos /home/lucas/fn_registry)
if [[ ${#diverged[@]} -gt 0 ]]; then
echo "ATENCION: repos que requieren intervencion manual:"
printf ' %s\n' "${diverged[@]}"
fi
```
## Estados de salida
| Linea stdout | Significado |
|---|---|
| `[pulled] repo` | Se trajo commits nuevos correctamente |
| `[up-to-date] repo` | Ya estaba al dia (o sin remote) |
| `[diverged] repo` | Pull --ff-only fallo — requiere rebase/merge manual |
| `[stash-conflict] repo` | Pull ok pero stash pop tuvo conflictos — requiere resolucion manual |
## Notas
Solo hace pull fast-forward — nunca rebase ni merge automatico. El stash incluye untracked (`--include-untracked`) para no perder archivos nuevos no trackeados. Exit 1 solo si `repo_dir` no es un repo git. Todos los demas casos (divergencia, conflictos, sin remote) retornan exit 0 con linea descriptiva.
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# git_pull_with_stash — Si el repo tiene cambios, los stashea antes de pullear.
# Hace fetch + pull --ff-only. Si hay divergencia, restaura el stash y reporta.
# Exit 0 siempre (el caller decide como manejar los errores).
git_pull_with_stash() {
local repo_dir="${1:-.}"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "git_pull_with_stash: '$repo_dir' no es un repo git" >&2
return 1
fi
local abs_repo
abs_repo="$(cd "$repo_dir" && pwd)"
local repo_name
repo_name="$(basename "$abs_repo")"
# Comprobar si hay cambios pendientes
local dirty
dirty=$(git -C "$abs_repo" status --porcelain | wc -l)
local stashed=0
if [[ "$dirty" -gt 0 ]]; then
git -C "$abs_repo" stash push \
--include-untracked \
-m "auto-stash before pull" \
>/dev/null 2>&1 || true
stashed=1
fi
# Fetch origin
git -C "$abs_repo" fetch origin >/dev/null 2>&1 || {
# Sin remote configurado o sin red — considerar up-to-date
if [[ "$stashed" -eq 1 ]]; then
git -C "$abs_repo" stash pop >/dev/null 2>&1 || true
fi
echo "[up-to-date] $repo_name (no remote)"
return 0
}
# Pull --ff-only
local pull_out
pull_out=$(git -C "$abs_repo" pull --ff-only 2>&1)
local pull_exit=$?
if [[ $pull_exit -ne 0 ]]; then
# Divergencia — restaurar stash y reportar
if [[ "$stashed" -eq 1 ]]; then
git -C "$abs_repo" stash pop >/dev/null 2>&1 || true
fi
echo "[diverged] $repo_name"
return 0
fi
# Pull exitoso — restaurar stash si habia
if [[ "$stashed" -eq 1 ]]; then
local pop_out
pop_out=$(git -C "$abs_repo" stash pop 2>&1)
local pop_exit=$?
if [[ $pop_exit -ne 0 ]]; then
echo "[stash-conflict] $repo_name"
return 0
fi
fi
# Determinar si se trajo algo nuevo
if echo "$pull_out" | grep -q "Already up to date"; then
echo "[up-to-date] $repo_name"
else
local commits
commits=$(echo "$pull_out" | grep -c 'Fast-forward\|commit\|Merge' || true)
echo "[pulled] $repo_name"
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
git_pull_with_stash "$@"
fi
+56
View File
@@ -0,0 +1,56 @@
---
name: git_push_if_ahead
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "git_push_if_ahead(repo_dir: string) -> stdout: status line"
description: "Decide si pushear un repo git usando solo refs locales (sin tocar la red para decidir). Sin upstream hace push -u; con upstream y ahead > 0 pushea; con 0 ahead salta. Si el push falla no aborta — emite [error] y exit 0."
tags: [git, push, infra, ahead, upstream]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: repo_dir
desc: "path al repo git a evaluar; default '.'"
output: "linea de estado por stdout: '[push -u] repo (branch)', '[push] repo (branch, N commits ahead)', '[skip] repo (up-to-date)' o '[error] repo: razon'"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/git_push_if_ahead.sh"
---
## Ejemplo
```bash
source bash/functions/infra/git_push_if_ahead.sh
# Pushear si hay commits locales
status=$(git_push_if_ahead /home/lucas/fn_registry)
echo "$status"
# [push] fn_registry (master, 3 commits ahead)
# o:
# [skip] fn_registry (up-to-date)
# Iterar sobre multiples repos
while IFS= read -r repo; do
git_push_if_ahead "$repo"
done < <(discover_git_repos /home/lucas/fn_registry)
```
## Estados de salida
| Linea stdout | Significado |
|---|---|
| `[push -u] repo (branch)` | Sin upstream — se hizo push -u origin |
| `[push] repo (branch, N ahead)` | Tenia commits locales — se pusheo |
| `[skip] repo (up-to-date)` | 0 ahead localmente — no se toco la red |
| `[error] repo: razon` | Push rechazo (non-fast-forward, etc.) — se reporta pero exit 0 |
## Notas
`rev-list --count @{u}..HEAD` solo lee refs locales, no hace fetch. Esto es correcto para un push-only workflow: si en otro PC se hizo push, aqui no tenemos nada local que pushear de todas formas. El error `[error]` tipicamente indica que la rama remote esta adelante — el caller debe sugerir `/full-git-pull`.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# git_push_if_ahead — Decide si pushear un repo git sin tocar la red para decidir.
# Usa refs locales (rev-list) para determinar si hay commits por enviar.
# Stdout: linea de estado con [push|push -u|skip|error].
git_push_if_ahead() {
local repo_dir="${1:-.}"
if [[ ! -d "$repo_dir/.git" ]]; then
echo "git_push_if_ahead: '$repo_dir' no es un repo git" >&2
return 1
fi
local abs_repo
abs_repo="$(cd "$repo_dir" && pwd)"
# Nombre corto para display (basename del path)
local repo_name
repo_name="$(basename "$abs_repo")"
# Rama actual
local branch
branch=$(git -C "$abs_repo" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ -z "$branch" || "$branch" == "HEAD" ]]; then
echo "[error] $repo_name: no se puede determinar la rama actual"
return 0
fi
# Comprobar si existe upstream configurado
local upstream
upstream=$(git -C "$abs_repo" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)
if [[ -z "$upstream" ]]; then
# Sin upstream: push -u para establecer tracking
echo "[push -u] $repo_name ($branch)" >&2
local push_out
push_out=$(git -C "$abs_repo" push -u origin "$branch" 2>&1) || {
echo "[error] $repo_name: $push_out"
return 0
}
echo "$push_out" | tail -3 >&2
echo "[push -u] $repo_name ($branch)"
return 0
fi
# Contar commits locales que no estan en upstream (sin red)
local ahead
ahead=$(git -C "$abs_repo" rev-list --count '@{u}..HEAD' 2>/dev/null || echo "0")
if [[ "${ahead:-0}" -gt 0 ]]; then
echo "[push] $repo_name ($branch, $ahead commits ahead)" >&2
local push_out
push_out=$(git -C "$abs_repo" push origin "$branch" 2>&1) || {
echo "[error] $repo_name: $(echo "$push_out" | tail -1)"
return 0
}
echo "$push_out" | tail -3 >&2
echo "[push] $repo_name ($branch, $ahead commits ahead)"
else
echo "[skip] $repo_name (up-to-date)"
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
git_push_if_ahead "$@"
fi
+49
View File
@@ -0,0 +1,49 @@
---
name: port_kill
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "port_kill(port: int, signal: string) -> void"
description: "Mata los procesos que escuchan en un puerto TCP dado. Idempotente: si no hay proceso en el puerto retorna exit 0. Hace un segundo intento con SIGKILL si el primer intento con signal no libera el puerto."
tags: ["port", "kill", "process", "tcp"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: port
desc: "Numero de puerto TCP (1-65535) cuyos procesos en estado LISTEN seran terminados."
- name: signal
desc: "Signal opcional para kill. Default TERM. Acepta KILL, INT, TERM, HUP o numerico (9, 15, ...). Si el proceso sobrevive, se reintenta automaticamente con KILL."
output: "Imprime a stdout una linea por cada PID matado (KILLED pid=X signal=Y port=Z) o NO_PROCESS si el puerto ya estaba libre. Errores a stderr. Exit codes: 0=OK, 2=puerto invalido, 3=puerto sigue ocupado tras SIGKILL, 4=permiso denegado, 5=ni lsof ni fuser disponibles."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/port_kill.sh"
---
## Ejemplo
```bash
source bash/functions/infra/port_kill.sh
# Matar proceso en puerto 8080 con SIGTERM (default)
port_kill 8080
# Matar proceso en puerto 3000 con SIGKILL directo
port_kill 3000 KILL
```
## Notas
Idempotente: si ningun proceso escucha en el puerto, imprime `NO_PROCESS port=<port>` y retorna 0 sin error.
Preferencia de herramienta: usa `lsof -ti tcp:<port> -sTCP:LISTEN` si esta disponible; fallback a `fuser -n tcp <port>`. Si ninguno esta instalado, retorna exit 5.
Logica de reintento: tras enviar la signal inicial espera 2 segundos. Si el puerto sigue ocupado y la signal no era KILL/9, realiza un segundo intento con SIGKILL. Si tras ese segundo intento el puerto sigue bloqueado, retorna exit 3.
Permisos: si los PIDs pertenecen a root y el invocador no tiene privilegios, `kill` fallara y se reporta `PERMISSION_DENIED pid=<pid>` a stderr con exit 4. Ejecutar con sudo si es necesario.
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# port_kill — Mata los procesos que escuchan en un puerto TCP dado.
port_kill() {
local port="${1:-}"
local signal="${2:-TERM}"
# Validar puerto
if [[ -z "$port" ]] || ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "ERROR: puerto invalido: '$port' (debe ser 1-65535)" >&2
return 2
fi
# Verificar herramienta disponible
local tool=""
if command -v lsof &>/dev/null; then
tool="lsof"
elif command -v fuser &>/dev/null; then
tool="fuser"
else
echo "ERROR: se requiere lsof o fuser (ninguno disponible)" >&2
return 5
fi
# Obtener PIDs
local pids=()
if [[ "$tool" == "lsof" ]]; then
mapfile -t pids < <(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null)
else
mapfile -t pids < <(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$')
fi
if (( ${#pids[@]} == 0 )); then
echo "NO_PROCESS port=${port}"
return 0
fi
# Matar cada PID
local permission_denied=0
for pid in "${pids[@]}"; do
[[ -z "$pid" ]] && continue
if kill "-${signal}" "$pid" 2>/dev/null; then
echo "KILLED pid=${pid} signal=${signal} port=${port}"
else
echo "PERMISSION_DENIED pid=${pid}" >&2
permission_denied=1
fi
done
if (( permission_denied )); then
return 4
fi
# Esperar 2s y verificar
sleep 2
local remaining=()
if [[ "$tool" == "lsof" ]]; then
mapfile -t remaining < <(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null)
else
mapfile -t remaining < <(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$')
fi
if (( ${#remaining[@]} == 0 )); then
return 0
fi
# Segundo intento con KILL si signal != KILL
if [[ "$signal" != "KILL" && "$signal" != "9" ]]; then
for pid in "${remaining[@]}"; do
[[ -z "$pid" ]] && continue
kill -KILL "$pid" 2>/dev/null && echo "KILLED pid=${pid} signal=KILL port=${port}"
done
sleep 1
local still=()
if [[ "$tool" == "lsof" ]]; then
mapfile -t still < <(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null)
else
mapfile -t still < <(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$')
fi
if (( ${#still[@]} > 0 )); then
echo "ERROR: puerto ${port} sigue ocupado tras SIGKILL" >&2
return 3
fi
else
echo "ERROR: puerto ${port} sigue ocupado tras SIGKILL" >&2
return 3
fi
return 0
}
@@ -0,0 +1,62 @@
---
name: pre_commit_hook_install
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "pre_commit_hook_install(repo_dir: string, [--force]) -> void"
description: "Instala un hook pre-commit en .git/hooks/pre-commit de un repo dado. El hook invoca scan_secrets_in_dirty para abortar el commit si detecta secrets en archivos staged. Idempotente: si el hook ya esta instalado (marca fn_registry-pre-commit-v1) no lo sobreescribe a menos que se pase --force."
tags: ["git", "hook", "precommit", "secrets"]
uses_functions: ["scan_secrets_in_dirty_bash_cybersecurity"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: repo_dir
desc: "Ruta al directorio raiz del repo Git (debe contener .git/hooks/)."
- name: --force
desc: "Flag opcional. Si se pasa, sobreescribe el hook aunque ya exista (hace backup con timestamp)."
output: "Imprime INSTALLED <path> o SKIP <path> (already installed). Retorna exit code 1 si repo_dir no es un repo git valido, 2 si el hook existe y no es nuestro sin --force."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/pre_commit_hook_install.sh"
---
## Ejemplo
```bash
source bash/functions/infra/pre_commit_hook_install.sh
# Instalar en el repo actual
pre_commit_hook_install /home/lucas/fn_registry
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
# Idempotente: segunda llamada no sobreescribe
pre_commit_hook_install /home/lucas/fn_registry
# SKIP /home/lucas/fn_registry/.git/hooks/pre-commit (already installed)
# Forzar reinstalacion (hace backup del hook anterior)
pre_commit_hook_install /home/lucas/fn_registry --force
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
```
## Notas
Idempotente por diseno: la marca `# fn_registry-pre-commit-v1` en el hook generado sirve como identificador. Si el hook existe y tiene la marca, la funcion no sobreescribe a menos que se pase `--force`.
Si el hook existe pero NO tiene la marca (es un hook ajeno), la funcion falla con exit 2 y un mensaje de error claro. Con `--force`, se hace backup a `pre-commit.bak.<timestamp>` antes de reescribir.
El hook generado localiza `fn_registry` en dos pasos:
1. La variable de entorno `FN_REGISTRY_ROOT` si esta definida.
2. Si el repo donde se hace commit tiene `registry.db` en la raiz, asume que el propio repo es `fn_registry`.
Si no puede localizar `fn_registry`, el hook imprime un aviso y sale con exit 0 (no bloquea el commit). Esto permite instalar el hook en repos externos al registry sin romper su flujo.
Configurar `FN_REGISTRY_ROOT` en el perfil del shell para garantizar que el hook siempre encuentre el registry:
```bash
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
```
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# pre_commit_hook_install — instala hook pre-commit que invoca scan_secrets_in_dirty
pre_commit_hook_install() {
local repo_dir="$1"
local force=0
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=1 ;;
esac
shift
done
local hooks_dir="$repo_dir/.git/hooks"
local hook_path="$hooks_dir/pre-commit"
local marker="# fn_registry-pre-commit-v1"
if [[ ! -d "$hooks_dir" ]]; then
echo "[pre_commit_hook_install] ERROR: '$repo_dir' no es un repo git valido (falta .git/hooks)" >&2
return 1
fi
if [[ -f "$hook_path" ]]; then
if grep -qF "$marker" "$hook_path"; then
if [[ $force -eq 0 ]]; then
echo "SKIP $hook_path (already installed)"
return 0
else
local backup="$hook_path.bak.$(date +%s)"
cp "$hook_path" "$backup"
echo "[pre_commit_hook_install] Backup: $backup" >&2
fi
else
if [[ $force -eq 0 ]]; then
echo "[pre_commit_hook_install] ERROR: '$hook_path' existe y no es nuestro. Usa --force para sobreescribir." >&2
return 2
fi
fi
fi
cat > "$hook_path" <<'HOOK'
#!/usr/bin/env bash
# fn_registry-pre-commit-v1
set -e
# Localizar fn_registry root: env var FN_REGISTRY_ROOT o asumir mismo repo si tiene registry.db en raiz
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-}"
if [ -z "$REGISTRY_ROOT" ]; then
REPO_ROOT="$(git rev-parse --show-toplevel)"
if [ -f "$REPO_ROOT/registry.db" ]; then
REGISTRY_ROOT="$REPO_ROOT"
fi
fi
if [ -z "$REGISTRY_ROOT" ] || [ ! -f "$REGISTRY_ROOT/bash/functions/cybersecurity/scan_secrets_in_dirty.sh" ]; then
echo "[pre-commit] fn_registry no localizable; saltando scan de secrets" >&2
exit 0
fi
# Ejecutar scan en repo actual (cwd)
bash "$REGISTRY_ROOT/bash/functions/cybersecurity/scan_secrets_in_dirty.sh" "$(git rev-parse --show-toplevel)"
HOOK
chmod +x "$hook_path"
echo "INSTALLED $hook_path"
return 0
}
@@ -0,0 +1,47 @@
---
name: resolve_cpp_app_dir
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "resolve_cpp_app_dir(app_name?: string) -> stdout: app_name\tapp_dir"
description: "Resuelve el nombre y directorio absoluto de una app C++ del registry. Sin arg deduce desde CWD si esta dentro de cpp/apps/<X>/ o projects/*/apps/<X>/. Con arg busca en ambas ubicaciones. Imprime '<app_name>TAB<absolute_dir>' en stdout, exit 0; si no resuelve, lista apps disponibles en stderr y sale con exit 1."
tags: [cpp, resolve, app, directory, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/resolve_cpp_app_dir.sh"
params:
- name: app_name
desc: "Nombre de la app C++ a resolver (opcional). Sin arg se deduce desde el directorio actual si estamos dentro de cpp/apps/<X>/ o projects/*/apps/<X>/."
output: "Una linea TAB-separada '<app_name>\\t<absolute_dir_path>' en stdout. En caso de error imprime ayuda a stderr y sale con exit 1."
---
## Ejemplo
```bash
# Desde dentro de cpp/apps/chart_demo/
cd /home/lucas/fn_registry/cpp/apps/chart_demo
resolve_cpp_app_dir
# -> chart_demo\t/home/lucas/fn_registry/cpp/apps/chart_demo
# Con argumento explicito
resolve_cpp_app_dir registry_dashboard
# -> registry_dashboard\t/home/lucas/fn_registry/cpp/apps/registry_dashboard
# Capturar los dos campos
resolved=$(resolve_cpp_app_dir graph_explorer)
APP="$(echo "$resolved" | cut -f1)"
APP_DIR="$(echo "$resolved" | cut -f2)"
```
## Notas
Busca en orden: primero `$ROOT/cpp/apps/<X>`, luego `$ROOT/projects/*/apps/<X>` (primer match gana). Si ninguna ruta existe, imprime lista de apps disponibles (con prefijo de ubicacion) en stderr y sale con exit 1. Sourceable o ejecutable directamente.
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# resolve_cpp_app_dir — Resuelve nombre y directorio absoluto de una app C++ del registry.
# Sin arg: deduce desde CWD si esta dentro de cpp/apps/<X>/ o projects/*/apps/<X>/.
# Con arg: usa el nombre directamente y busca en ambas ubicaciones.
# Salida: "<app_name>\t<absolute_dir_path>" en stdout (TAB separado), exit 0.
# Error: lista apps disponibles en stderr + exit 1.
resolve_cpp_app_dir() {
local app_arg="${1:-}"
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
# --- Deducir desde CWD si no hay argumento ---
if [ -z "$app_arg" ]; then
local cwd
cwd="$(pwd)"
case "$cwd" in
"$root"/cpp/apps/*/|"$root"/cpp/apps/*)
# Extraer primer segmento tras cpp/apps/
local rel="${cwd#"$root/cpp/apps/"}"
app_arg="${rel%%/*}"
;;
"$root"/projects/*/apps/*/|"$root"/projects/*/apps/*)
# Extraer primer segmento tras la ultima /apps/
local rel="${cwd#"$root/projects/"}"
rel="${rel#*/apps/}"
app_arg="${rel%%/*}"
;;
esac
fi
# --- Aun vacio: listar y abortar ---
if [ -z "$app_arg" ]; then
echo "ERROR: no se pudo deducir la app desde el directorio actual." >&2
echo "" >&2
echo "Apps disponibles:" >&2
{
ls "$root/cpp/apps/" 2>/dev/null | sed 's/^/ cpp\/apps\//'
for proj in "$root"/projects/*/apps/; do
ls "$proj" 2>/dev/null | sed "s|^| $(echo "$proj" | sed "s|$root/||")|"
done
} >&2
echo "" >&2
echo "Uso: resolve_cpp_app_dir <app_name>" >&2
return 1
fi
# --- Buscar directorio real ---
local app_dir=""
# Primero: cpp/apps/<X>
if [ -d "$root/cpp/apps/$app_arg" ]; then
app_dir="$root/cpp/apps/$app_arg"
fi
# Segundo: projects/*/apps/<X> (primer match)
if [ -z "$app_dir" ]; then
for cand in "$root"/projects/*/apps/"$app_arg"; do
if [ -d "$cand" ]; then
app_dir="$cand"
break
fi
done
fi
if [ -z "$app_dir" ]; then
echo "ERROR: no se encuentra app '$app_arg' en cpp/apps/ ni en projects/*/apps/" >&2
echo "" >&2
echo "Apps disponibles:" >&2
{
ls "$root/cpp/apps/" 2>/dev/null | sed 's/^/ cpp\/apps\//'
for proj in "$root"/projects/*/apps/; do
ls "$proj" 2>/dev/null | sed "s|^| $(echo "$proj" | sed "s|$root/||")|"
done
} >&2
return 1
fi
printf '%s\t%s\n' "$app_arg" "$app_dir"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
resolve_cpp_app_dir "$@"
fi
+57
View File
@@ -0,0 +1,57 @@
---
name: rotate_backups
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "rotate_backups(dir: string, new_file: string, daily: int, weekly: int, monthly: int) -> string"
description: "Aplica retention policy estilo rsnapshot (daily/weekly/monthly) sobre un directorio de backups. Mueve el backup recien creado a daily.0, desplaza los anteriores y promueve a weekly/monthly al fin de periodo."
tags: ["backup", "rotate", "retention"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: dir
desc: "Directorio donde viven los backups. Se crea si no existe."
- name: new_file
desc: "Ruta al backup recien creado. Se mueve dentro de dir como daily.0."
- name: daily
desc: "Numero de slots diarios a conservar. Por defecto 7."
- name: weekly
desc: "Numero de slots semanales a conservar (promovidos cada domingo). Por defecto 4."
- name: monthly
desc: "Numero de slots mensuales a conservar (promovidos el dia 1 de cada mes). Por defecto 12."
output: "Linea ROTATED daily=<N> weekly=<N> monthly=<N> dir=<dir> en stdout con el conteo de slots ocupados tras la rotacion. Exit code != 0 en error (1: new_file no existe, 2: dir no se puede crear, 3: argumento numerico invalido)."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/rotate_backups.sh"
---
## Ejemplo
```bash
source bash/functions/infra/rotate_backups.sh
rotate_backups ~/backups/registry /tmp/registry-snap.db 7 4 12
# ROTATED daily=1 weekly=0 monthly=0 dir=/root/backups/registry
# Con key=value
rotate_backups ~/backups/pg /tmp/pg-dump.sql daily=7 weekly=4 monthly=12
```
## Notas
Estilo rsnapshot: los slots se numeran desde 0 (mas reciente). El algoritmo aplica rotaciones en orden inverso para no sobreescribir:
1. **Monthly** (dia 1): copia `weekly.<weekly-1>``monthly.0` antes de desplazar, borra el mas viejo.
2. **Weekly** (domingo): copia `daily.<daily-1>``weekly.0` antes de desplazar, borra el mas viejo.
3. **Daily** (siempre): borra `daily.<daily-1>`, desplaza daily.i → daily.i+1, mueve new_file → daily.0.
La promocion weekly y monthly se ejecuta ANTES de la rotacion daily para que `daily.<daily-1>` y `weekly.<weekly-1>` aun existan cuando se necesitan. Sin dependencias externas — solo `mv`, `rm`, `cp`, `mkdir`, `date`.
Los slots pueden ser archivos o directorios (caller decide el formato del backup).
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# rotate_backups — retention policy estilo rsnapshot (daily/weekly/monthly)
rotate_backups() {
local dir="$1"
local new_file="$2"
local daily=7
local weekly=4
local monthly=12
# Parsear args posicionales o key=value
local i=3
for arg in "${@:3}"; do
case "$arg" in
daily=*) daily="${arg#daily=}" ;;
weekly=*) weekly="${arg#weekly=}" ;;
monthly=*) monthly="${arg#monthly=}" ;;
*)
case $i in
3) daily="$arg" ;;
4) weekly="$arg" ;;
5) monthly="$arg" ;;
esac
;;
esac
((i++))
done
# Validar numericos
if ! [[ "$daily" =~ ^[0-9]+$ ]] || ! [[ "$weekly" =~ ^[0-9]+$ ]] || ! [[ "$monthly" =~ ^[0-9]+$ ]]; then
echo "ERROR: daily, weekly y monthly deben ser enteros positivos" >&2
return 3
fi
# Validar new_file
if [[ ! -e "$new_file" ]]; then
echo "ERROR: new_file no existe: $new_file" >&2
return 1
fi
# Crear directorio si no existe
if ! mkdir -p "$dir" 2>/dev/null; then
echo "ERROR: no se pudo crear el directorio: $dir" >&2
return 2
fi
local dow
dow=$(date +%w) # 0=domingo
local dom
dom=$(date +%d) # 01-31
local rotated_daily=0
local rotated_weekly=0
local rotated_monthly=0
# --- Monthly: si dia 1, rotar desde weekly.<weekly-1> ANTES de borrar ---
if [[ "$dom" == "01" ]] && (( monthly > 0 )); then
local weekly_src="$dir/weekly.$((weekly - 1))"
if [[ -e "$weekly_src" ]]; then
# Borrar monthly mas viejo
local oldest_monthly="$dir/monthly.$((monthly - 1))"
[[ -e "$oldest_monthly" ]] && rm -rf "$oldest_monthly"
# Desplazar monthly.<i> → monthly.<i+1>
for (( j = monthly - 2; j >= 0; j-- )); do
[[ -e "$dir/monthly.$j" ]] && mv "$dir/monthly.$j" "$dir/monthly.$((j+1))"
done
cp -a "$weekly_src" "$dir/monthly.0"
rotated_monthly=1
fi
fi
# --- Weekly: si domingo, rotar desde daily.<daily-1> ANTES de borrarlo ---
if [[ "$dow" == "0" ]] && (( weekly > 0 )); then
local daily_src="$dir/daily.$((daily - 1))"
if [[ -e "$daily_src" ]]; then
# Borrar weekly mas viejo
local oldest_weekly="$dir/weekly.$((weekly - 1))"
[[ -e "$oldest_weekly" ]] && rm -rf "$oldest_weekly"
# Desplazar weekly.<i> → weekly.<i+1>
for (( j = weekly - 2; j >= 0; j-- )); do
[[ -e "$dir/weekly.$j" ]] && mv "$dir/weekly.$j" "$dir/weekly.$((j+1))"
done
cp -a "$daily_src" "$dir/weekly.0"
rotated_weekly=1
fi
fi
# --- Daily: borrar el mas viejo, desplazar, mover new_file a daily.0 ---
local oldest_daily="$dir/daily.$((daily - 1))"
[[ -e "$oldest_daily" ]] && rm -rf "$oldest_daily"
for (( j = daily - 2; j >= 0; j-- )); do
[[ -e "$dir/daily.$j" ]] && mv "$dir/daily.$j" "$dir/daily.$((j+1))"
done
mv "$new_file" "$dir/daily.0"
rotated_daily=1
# Contar slots ocupados para el reporte
local cnt_daily=0 cnt_weekly=0 cnt_monthly=0
for (( j = 0; j < daily; j++ )); do [[ -e "$dir/daily.$j" ]] && ((cnt_daily++)); done
for (( j = 0; j < weekly; j++ )); do [[ -e "$dir/weekly.$j" ]] && ((cnt_weekly++)); done
for (( j = 0; j < monthly; j++ )); do [[ -e "$dir/monthly.$j" ]] && ((cnt_monthly++)); done
echo "ROTATED daily=$cnt_daily weekly=$cnt_weekly monthly=$cnt_monthly dir=$dir"
}
+60
View File
@@ -0,0 +1,60 @@
---
name: tail_journal
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "tail_journal(unit: string, lines: int=100, follow: bool=false, since: string=\"\", priority: string=\"info\") -> void"
description: "Wrapper sobre journalctl con formato consistente. Tail logs de una unidad systemd con coloreado, filtro por prioridad y seguimiento opcional."
tags: ["journal", "systemd", "logs"]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: unit
desc: "Nombre de la unidad systemd (ej. registry-api.service, caddy). Acepta sin extension .service."
- name: lines
desc: "Numero de lineas iniciales a mostrar. Default 100."
- name: follow
desc: "Si true o -f, activa seguimiento continuo (journalctl -f). Default false."
- name: since
desc: "Filtro temporal: '1 hour ago', 'yesterday', '2026-05-07'. Default vacio (sin filtro)."
- name: priority
desc: "Prioridad minima de logs: emerg|alert|crit|err|warning|notice|info|debug. Default info."
output: "Lineas de journalctl en formato short-iso, una por linea. Si follow=true, flujo continuo."
example: "tail_journal registry-api 200 true"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/tail_journal.sh"
---
## Ejemplo
```bash
# Ver ultimas 100 lineas de un servicio
tail_journal caddy
# Seguimiento continuo con 50 lineas iniciales
tail_journal registry-api.service 50 true
# Solo errores de la ultima hora
tail_journal my-app 200 false "1 hour ago" err
# Con pipe (funciona sin bufferizado)
tail_journal registry-api 100 true "" warning | grep "ERROR"
```
## Notas
Detecta automaticamente si la unit pertenece al usuario (systemctl --user) o al sistema (sudo journalctl). Si la unit no es de usuario, se llama con sudo — el usuario debe tener NOPASSWD para journalctl o sudo configurado.
Si follow=true, usa `stdbuf -oL` para deshabilitar el bufferizado de stdout, lo que permite piping en tiempo real (ej. `tail_journal ... | grep pattern`).
Exit codes: 1 unit no existe o no especificada, 2 prioridad invalida, 5 journalctl no disponible.
```
---
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# tail_journal — wrapper sobre journalctl con formato consistente
tail_journal() {
local unit="${1:-}"
local lines="${2:-100}"
local follow="${3:-false}"
local since="${4:-}"
local priority="${5:-info}"
if [[ -z "$unit" ]]; then
echo "tail_journal: unit requerida" >&2
return 1
fi
# Normalizar nombre de unit (añadir .service si no tiene extension)
local unit_full="$unit"
if [[ "$unit" != *.* ]]; then
unit_full="${unit}.service"
fi
# Validar prioridad
case "$priority" in
emerg|alert|crit|err|warning|notice|info|debug) ;;
*)
echo "tail_journal: prioridad invalida '$priority'. Valores: emerg alert crit err warning notice info debug" >&2
return 2
;;
esac
# Verificar que journalctl esta disponible
if ! command -v journalctl &>/dev/null; then
echo "tail_journal: journalctl no disponible" >&2
return 5
fi
# Detectar si la unit es de usuario o de sistema
local user_flag=""
if systemctl --user list-units --all 2>/dev/null | grep -q "$unit_full"; then
user_flag="--user"
fi
# Construir comando base
local -a cmd
if [[ -z "$user_flag" ]]; then
cmd=(sudo journalctl)
else
cmd=(journalctl --user)
fi
cmd+=(-u "$unit_full" -n "$lines" -p "$priority" --output=short-iso)
if [[ -n "$since" ]]; then
cmd+=(--since "$since")
fi
local follow_flag=false
if [[ "$follow" == "true" || "$follow" == "-f" ]]; then
follow_flag=true
cmd+=(-f)
fi
# Verificar que la unit existe (solo si no hay user_flag y no es sudo)
if [[ -n "$user_flag" ]]; then
if ! systemctl --user list-units --all 2>/dev/null | grep -q "$unit_full"; then
echo "tail_journal: unit '$unit_full' no encontrada" >&2
return 1
fi
fi
# Ejecutar sin bufferizar si follow=true
if [[ "$follow_flag" == "true" ]]; then
stdbuf -oL "${cmd[@]}"
else
"${cmd[@]}"
fi
}
+53
View File
@@ -0,0 +1,53 @@
---
name: tbd_branch_create
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "tbd_branch_create(mode: string, ...args: string) -> void"
description: "Crea una rama TBD (trunk-based development) desde master/main actualizado. Soporta modos 'issue <NNNN> <slug>' y 'quick <slug>'. Autodetecta la rama base (master/main), verifica working tree limpio, hace pull --rebase y crea la rama. Valida formato de numero de issue (4 digitos) y slug (kebab-case ASCII)."
tags: [git, tbd, branch, trunk-based-development, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: mode
desc: "Modo de creacion: 'issue' para ramas issue/<NNNN>-<slug> o 'quick' para ramas quick/<slug>."
- name: args
desc: "Para mode=issue: NNNN (4 digitos) + slug (kebab-case). Para mode=quick: solo slug (kebab-case)."
output: "Crea la rama y cambia a ella. Imprime confirmacion a stdout. Exit 1 en caso de error (dirty tree, formato invalido, repo inexistente)."
tested: true
tests:
- "issue branch created"
- "quick branch created"
- "issue number must be 4 digits"
- "slug must be kebab-case"
- "invalid mode exits 1"
- "no args exits 1"
- "dirty tree exits 1"
- "existing branch exits 1"
- "works with main as base"
test_file_path: "bash/functions/infra/tbd_branch_create.sh"
file_path: "bash/functions/infra/tbd_branch_create.sh"
---
## Ejemplo
```bash
# Crear rama para un issue
tbd_branch_create issue 0042 add-auth
# Crear rama para cambio rapido
tbd_branch_create quick fix-typo-readme
# Ejecutar suite de tests
bash bash/functions/infra/tbd_branch_create.sh --test
```
## Notas
La funcion autodetecta la rama base probando primero `master` y luego `main` con `git show-ref --verify`. Si el directorio actual no es un repo git, sale con error. Si el working tree tiene cambios no commiteados, sale con error antes de hacer pull. Los tests internos se ejecutan con `--test` y crean repos temporales aislados con `mktemp -d`.
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env bash
# tbd_branch_create — crea rama TBD desde master/main actualizado
#
# Uso:
# tbd_branch_create issue <NNNN> <slug>
# tbd_branch_create quick <slug>
#
# Opciones especiales:
# --test ejecutar suite de tests internos y salir
set -euo pipefail
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
_tbd_detect_base() {
# Retorna 'master' o 'main' — el primero que exista localmente
if git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
echo "master"
elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
echo "main"
else
echo ""
fi
}
_tbd_require_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "ERROR: el directorio actual no es un repositorio git." >&2
return 1
fi
}
_tbd_pull_rebase() {
# Hace pull --rebase solo si hay upstream configurado; si no, es no-op.
local has_upstream
has_upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo "")
if [[ -n "$has_upstream" ]]; then
git pull --rebase
else
echo "(sin remote upstream — saltando pull)"
fi
}
# ---------------------------------------------------------------------------
# funcion principal
# ---------------------------------------------------------------------------
tbd_branch_create() {
local mode="${1:-}"
if [[ -z "$mode" ]] || [[ "$mode" != "issue" && "$mode" != "quick" ]]; then
echo "Uso: tbd_branch_create issue <NNNN> <slug>" >&2
echo " tbd_branch_create quick <slug>" >&2
return 1
fi
# Verificar repo git
_tbd_require_git_repo
# Detectar rama base
local base
base=$(_tbd_detect_base)
if [[ -z "$base" ]]; then
echo "ERROR: no se encontro rama 'master' ni 'main' en el repo local." >&2
return 1
fi
# Construir nombre de rama segun modo
local branch_name
if [[ "$mode" == "issue" ]]; then
local num="${2:-}"
local slug="${3:-}"
if [[ -z "$num" || -z "$slug" ]]; then
echo "Uso: tbd_branch_create issue <NNNN> <slug>" >&2
return 1
fi
if [[ ! "$num" =~ ^[0-9]{4}$ ]]; then
echo "ERROR: <NNNN> debe ser exactamente 4 digitos numericos (ej: 0042)." >&2
return 1
fi
if [[ ! "$slug" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "ERROR: slug debe ser kebab-case ASCII (ej: fix-typo, add-auth)." >&2
return 1
fi
branch_name="issue/${num}-${slug}"
else
# quick
local slug="${2:-}"
if [[ -z "$slug" ]]; then
echo "Uso: tbd_branch_create quick <slug>" >&2
return 1
fi
if [[ ! "$slug" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "ERROR: slug debe ser kebab-case ASCII (ej: fix-typo, update-readme)." >&2
return 1
fi
branch_name="quick/${slug}"
fi
# Verificar si la rama ya existe
if git show-ref --verify --quiet "refs/heads/${branch_name}" 2>/dev/null; then
echo "ERROR: la rama '${branch_name}' ya existe localmente." >&2
return 1
fi
# Asegurarse de estar en la rama base
local current
current=$(git branch --show-current)
if [[ "$current" != "$base" ]]; then
echo "Cambiando a ${base}..."
git checkout "$base"
fi
# Verificar working tree limpio
local dirty
dirty=$(git status --porcelain)
if [[ -n "$dirty" ]]; then
echo "ERROR: working tree dirty. Commit o stash los cambios antes de crear la rama." >&2
return 1
fi
# Actualizar base desde remote si hay upstream
echo "Actualizando ${base}..."
_tbd_pull_rebase
# Crear la rama
git checkout -b "$branch_name"
echo "Rama '${branch_name}' creada desde ${base} actualizada."
return 0
}
# ---------------------------------------------------------------------------
# modo --test
# ---------------------------------------------------------------------------
_tbd_branch_create_tests() {
local PASS=0
local FAIL=0
_assert_eq() {
local name="$1" expected="$2" got="$3"
if [[ "$expected" == "$got" ]]; then
echo "PASS: $name"
PASS=$((PASS + 1))
else
echo "FAIL: $name — expected '${expected}', got '${got}'"
FAIL=$((FAIL + 1))
fi
}
_assert_exit() {
local name="$1" expected_exit="$2"
shift 2
local got_exit=0
"$@" > /dev/null 2>&1 || got_exit=$?
if [[ "$expected_exit" == "$got_exit" ]]; then
echo "PASS: $name (exit ${got_exit})"
PASS=$((PASS + 1))
else
echo "FAIL: $name — expected exit ${expected_exit}, got ${got_exit}"
FAIL=$((FAIL + 1))
fi
}
# ---------------------------------------------------------------------------
# Setup: bare remote + repo clonado (master)
# ---------------------------------------------------------------------------
local tmproot
tmproot=$(mktemp -d)
local bare="$tmproot/remote.git"
local work="$tmproot/work"
git -c init.defaultBranch=master init --bare -q "$bare"
git clone -q "$bare" "$work"
(
cd "$work"
git config user.email "test@test.com"
git config user.name "Test"
echo "init" > README.md
git add README.md
git commit -q -m "chore: init"
git push -q -u origin master
)
# ---------------------------------------------------------------------------
# Test: rama issue valida
# ---------------------------------------------------------------------------
(
cd "$work"
tbd_branch_create issue 0042 add-auth
) > /dev/null 2>&1
local result
result=$(cd "$work" && git branch --show-current)
_assert_eq "issue branch created" "issue/0042-add-auth" "$result"
# Volver a master
(cd "$work" && git checkout -q master)
# ---------------------------------------------------------------------------
# Test: rama quick valida
# ---------------------------------------------------------------------------
(
cd "$work"
tbd_branch_create quick fix-typo
) > /dev/null 2>&1
result=$(cd "$work" && git branch --show-current)
_assert_eq "quick branch created" "quick/fix-typo" "$result"
(cd "$work" && git checkout -q master)
# ---------------------------------------------------------------------------
# Test: numero de issue invalido (3 digitos)
# ---------------------------------------------------------------------------
_assert_exit "issue number must be 4 digits" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create issue 042 fix
"
# ---------------------------------------------------------------------------
# Test: slug invalido (mayusculas)
# ---------------------------------------------------------------------------
_assert_exit "slug must be kebab-case" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create issue 0001 Fix-Typo
"
# ---------------------------------------------------------------------------
# Test: modo invalido
# ---------------------------------------------------------------------------
_assert_exit "invalid mode exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create hotfix my-slug
"
# ---------------------------------------------------------------------------
# Test: sin argumentos
# ---------------------------------------------------------------------------
_assert_exit "no args exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create
"
# ---------------------------------------------------------------------------
# Test: working tree dirty → error
# ---------------------------------------------------------------------------
echo "dirty" > "$work/dirty.txt"
_assert_exit "dirty tree exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create quick clean-slug
"
rm -f "$work/dirty.txt"
# ---------------------------------------------------------------------------
# Test: rama ya existe → error
# ---------------------------------------------------------------------------
(cd "$work" && git checkout -q -b issue/0099-existing && git checkout -q master)
_assert_exit "existing branch exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_create issue 0099 existing
"
# ---------------------------------------------------------------------------
# Test: funciona con 'main' como rama base
# ---------------------------------------------------------------------------
local bare2="$tmproot/remote2.git"
local work2="$tmproot/work2"
git -c init.defaultBranch=main init --bare -q "$bare2"
git clone -q "$bare2" "$work2"
(
cd "$work2"
git config user.email "test@test.com"
git config user.name "Test"
echo "init" > README.md
git add README.md
git commit -q -m "chore: init"
git push -q -u origin main
tbd_branch_create quick use-main
) > /dev/null 2>&1
result=$(cd "$work2" && git branch --show-current)
_assert_eq "works with main as base" "quick/use-main" "$result"
# ---------------------------------------------------------------------------
# Cleanup y resultado
# ---------------------------------------------------------------------------
rm -rf "$tmproot"
echo "---"
echo "Results: ${PASS} passed, ${FAIL} failed"
[[ $FAIL -eq 0 ]] || exit 1
}
# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
if [[ "${1:-}" == "--test" ]]; then
_tbd_branch_create_tests
else
tbd_branch_create "$@"
fi
fi
+52
View File
@@ -0,0 +1,52 @@
---
name: tbd_branch_finish
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "tbd_branch_finish([merge_title: string]) -> void"
description: "Integra una rama TBD (issue/* o quick/*) a master/main con merge --no-ff, publica el merge al remote y elimina la rama local. Autodetecta la rama base (master/main), verifica working tree limpio y construye el titulo del merge commit. NO ejecuta tests — esa responsabilidad es del caller. Exit 2 si hay conflicto de merge (deja al usuario resolver)."
tags: [git, tbd, merge, trunk-based-development, workflow]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: merge_title
desc: "Titulo opcional del merge commit. Si se provee, el commit sera 'merge: <rama> — <merge_title>'. Si no, sera 'merge: <rama>'."
output: "Mergea la rama a base, hace push y elimina la rama local. Imprime confirmacion a stdout. Exit 1 en error (dirty tree, push fallido, no en rama TBD). Exit 2 si hay conflicto de merge (merge iniciado pero no resuelto — el usuario debe resolver manualmente)."
tested: true
tests:
- "finish lands on base branch"
- "issue branch deleted locally"
- "finish exits 0"
- "merge commit pushed to remote"
- "quick branch finish lands on master"
- "merge title included in commit"
- "dirty tree exits 1"
- "on master exits 1"
- "non-TBD branch exits 1"
- "works with main as base"
test_file_path: "bash/functions/infra/tbd_branch_finish.sh"
file_path: "bash/functions/infra/tbd_branch_finish.sh"
---
## Ejemplo
```bash
# Desde una rama issue/0042-add-auth, sin titulo adicional
tbd_branch_finish
# Con titulo descriptivo en el merge commit
tbd_branch_finish "implementar autenticacion OAuth"
# Ejecutar suite de tests
bash bash/functions/infra/tbd_branch_finish.sh --test
```
## Notas
La funcion NO ejecuta tests del proyecto — esa responsabilidad es del caller (slash command, agente, CI) porque depende del stack (go test, pytest, ctest, etc.). Si el push falla, la rama local NO se elimina para no perder trabajo. Si hay conflicto de merge, sale con exit 2 dejando el repo en estado de merge-en-progreso para que el usuario resuelva con `git add` + `git commit`. Los tests internos usan un remote bare simulado con `git init --bare` para validar el push completo.
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env bash
# tbd_branch_finish — integra rama TBD a master/main y publica
#
# Uso:
# tbd_branch_finish [<merge_title>]
#
# Opciones especiales:
# --test ejecutar suite de tests internos y salir
set -euo pipefail
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
_tbd_finish_detect_base() {
if git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
echo "master"
elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
echo "main"
else
echo ""
fi
}
_tbd_finish_require_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "ERROR: el directorio actual no es un repositorio git." >&2
return 1
fi
}
# ---------------------------------------------------------------------------
# funcion principal
# ---------------------------------------------------------------------------
tbd_branch_finish() {
local merge_title="${1:-}"
# Verificar repo git
_tbd_finish_require_git_repo
# Detectar rama actual
local branch
branch=$(git branch --show-current)
# Validar que es una rama TBD (issue/* o quick/*)
if [[ "$branch" != issue/* && "$branch" != quick/* ]]; then
if [[ "$branch" == "master" || "$branch" == "main" ]]; then
echo "ERROR: ya estas en ${branch}, nada que mergear." >&2
return 1
else
echo "ERROR: la rama actual '${branch}' no es una rama TBD (issue/* o quick/*)." >&2
return 1
fi
fi
# Verificar working tree limpio
local dirty
dirty=$(git status --porcelain)
if [[ -n "$dirty" ]]; then
echo "ERROR: hay cambios sin commitear. Haz commit antes de mergear." >&2
return 1
fi
# Detectar rama base
local base
base=$(_tbd_finish_detect_base)
if [[ -z "$base" ]]; then
echo "ERROR: no se encontro rama 'master' ni 'main' en el repo local." >&2
return 1
fi
# Componer titulo del merge commit
local title
if [[ -n "$merge_title" ]]; then
title="merge: ${branch}${merge_title}"
else
title="merge: ${branch}"
fi
# Cambiar a base y actualizar
echo "Cambiando a ${base}..."
git checkout "$base"
echo "Actualizando ${base}..."
git pull --rebase
# Merge --no-ff
echo "Mergeando ${branch} en ${base}..."
local merge_exit=0
git merge --no-ff "$branch" -m "$title" || merge_exit=$?
if [[ $merge_exit -ne 0 ]]; then
echo "merge conflict: resolver manualmente y continuar (git add + git commit)." >&2
return 2
fi
# Push
echo "Publicando ${base}..."
local push_exit=0
git push || push_exit=$?
if [[ $push_exit -ne 0 ]]; then
echo "ERROR: git push fallo (exit ${push_exit}). La rama '${branch}' NO fue eliminada." >&2
return 1
fi
# Eliminar rama local
git branch -d "$branch"
echo "Rama '${branch}' integrada a ${base} y publicada. Rama local eliminada."
return 0
}
# ---------------------------------------------------------------------------
# modo --test
# ---------------------------------------------------------------------------
_tbd_branch_finish_tests() {
local PASS=0
local FAIL=0
_assert_eq() {
local name="$1" expected="$2" got="$3"
if [[ "$expected" == "$got" ]]; then
echo "PASS: $name"
PASS=$((PASS + 1))
else
echo "FAIL: $name — expected '${expected}', got '${got}'"
FAIL=$((FAIL + 1))
fi
}
_assert_exit() {
local name="$1" expected_exit="$2"
shift 2
local got_exit=0
"$@" > /dev/null 2>&1 || got_exit=$?
if [[ "$expected_exit" == "$got_exit" ]]; then
echo "PASS: $name (exit ${got_exit})"
PASS=$((PASS + 1))
else
echo "FAIL: $name — expected exit ${expected_exit}, got ${got_exit}"
FAIL=$((FAIL + 1))
fi
}
# ---------------------------------------------------------------------------
# Setup: bare remote + repo de trabajo
# ---------------------------------------------------------------------------
local tmpdir
tmpdir=$(mktemp -d)
trap "rm -rf '$tmpdir'" EXIT
local bare="$tmpdir/remote.git"
local work="$tmpdir/work"
# Crear remote bare
git -c init.defaultBranch=master init --bare -q "$bare"
# Clonar para tener origin
git clone -q "$bare" "$work"
(
cd "$work"
git config user.email "test@test.com"
git config user.name "Test"
echo "init" > README.md
git add README.md
git commit -q -m "chore: init"
git push -q -u origin master
)
# ---------------------------------------------------------------------------
# Test: merge issue branch sin titulo
# ---------------------------------------------------------------------------
(
cd "$work"
git checkout -q -b issue/0001-add-feature
echo "feature" > feature.txt
git add feature.txt
git commit -q -m "feat: add feature"
)
local finish_exit=0
(
cd "$work"
tbd_branch_finish
) > /dev/null 2>&1 || finish_exit=$?
local current
current=$(cd "$work" && git branch --show-current)
_assert_eq "finish lands on base branch" "master" "$current"
local branch_gone=0
(cd "$work" && git show-ref --verify --quiet refs/heads/issue/0001-add-feature) && branch_gone=1 || branch_gone=0
_assert_eq "issue branch deleted locally" "0" "$branch_gone"
_assert_eq "finish exits 0" "0" "$finish_exit"
# Verificar merge commit en remote
local remote_log
remote_log=$(cd "$work" && git log --oneline -2 origin/master)
local has_merge=0
[[ "$remote_log" == *"merge: issue/0001-add-feature"* ]] && has_merge=1 || has_merge=0
_assert_eq "merge commit pushed to remote" "1" "$has_merge"
# ---------------------------------------------------------------------------
# Test: merge quick branch con titulo
# ---------------------------------------------------------------------------
(
cd "$work"
git checkout -q -b quick/fix-typo
echo "fix" > fix.txt
git add fix.txt
git commit -q -m "fix: typo"
)
(
cd "$work"
tbd_branch_finish "corregir typo en README"
) > /dev/null 2>&1
current=$(cd "$work" && git branch --show-current)
_assert_eq "quick branch finish lands on master" "master" "$current"
remote_log=$(cd "$work" && git log --oneline -2 origin/master)
local has_title=0
[[ "$remote_log" == *"merge: quick/fix-typo — corregir typo en README"* ]] && has_title=1 || has_title=0
_assert_eq "merge title included in commit" "1" "$has_title"
# ---------------------------------------------------------------------------
# Test: dirty tree → exit 1
# ---------------------------------------------------------------------------
(
cd "$work"
git checkout -q -b quick/dirty-test
)
echo "dirty" > "$work/dirty.txt"
_assert_exit "dirty tree exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_finish
"
rm -f "$work/dirty.txt"
(cd "$work" && git checkout -q master)
(cd "$work" && git branch -D quick/dirty-test 2>/dev/null || true)
# ---------------------------------------------------------------------------
# Test: ya en master → exit 1
# ---------------------------------------------------------------------------
_assert_exit "on master exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_finish
"
# ---------------------------------------------------------------------------
# Test: rama no TBD → exit 1
# ---------------------------------------------------------------------------
(cd "$work" && git checkout -q -b feature/non-tbd 2>/dev/null)
_assert_exit "non-TBD branch exits 1" 1 bash -c "
cd '$work'
source '${BASH_SOURCE[0]}'
tbd_branch_finish
"
(cd "$work" && git checkout -q master && git branch -D feature/non-tbd 2>/dev/null || true)
# ---------------------------------------------------------------------------
# Test: funciona con 'main' como rama base
# ---------------------------------------------------------------------------
local tmpdir2
tmpdir2=$(mktemp -d)
local bare2="$tmpdir2/remote.git"
local work2="$tmpdir2/work"
git -c init.defaultBranch=main init --bare -q "$bare2"
git clone -q "$bare2" "$work2"
(
cd "$work2"
git config user.email "test@test.com"
git config user.name "Test"
echo "init" > README.md
git add README.md
git commit -q -m "chore: init"
git push -q -u origin main
git checkout -q -b quick/use-main
echo "x" > x.txt
git add x.txt
git commit -q -m "feat: x"
tbd_branch_finish
) > /dev/null 2>&1
current=$(cd "$work2" && git branch --show-current)
_assert_eq "works with main as base" "main" "$current"
rm -rf "$tmpdir2"
echo "---"
echo "Results: ${PASS} passed, ${FAIL} failed"
[[ $FAIL -eq 0 ]] || exit 1
}
# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
if [[ "${1:-}" == "--test" ]]; then
_tbd_branch_finish_tests
else
tbd_branch_finish "$@"
fi
fi
+50
View File
@@ -0,0 +1,50 @@
---
name: wait_for_http
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wait_for_http <url> [timeout_seconds] [interval_seconds]"
description: "Hace polling a una URL HTTP/HTTPS hasta recibir respuesta 2xx o agotar el timeout. Util en deploys, post-restart de servicios y smoke tests."
tags: [http, wait, poll, health, deploy, smoke-test]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: url
desc: "URL completa del endpoint a sondear (debe empezar por http:// o https://)"
- name: timeout_seconds
desc: "Tiempo maximo de espera en segundos. Default: 30"
- name: interval_seconds
desc: "Intervalo entre intentos en segundos. Default: 1"
output: "stdout: 'OK <url> (<elapsed>s)' al primer 2xx. stderr: linea de progreso por intento y mensaje TIMEOUT si se agota. Exit 0=ok, 1=timeout, 2=URL invalida, 5=curl no instalado."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wait_for_http.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wait_for_http.sh
# Esperar hasta 60 segundos con sondeo cada 2 segundos
wait_for_http https://api.example.com/health 60 2
# Uso tipico en un pipeline de deploy
wait_for_http http://localhost:8080/health || { echo "servicio no arranco"; exit 1; }
```
## Notas
Acepta cualquier codigo 2xx (200, 201, 204...) como OK. Los codigos 3xx se tratan como "no listo" — el servicio debe responder directamente con 2xx, no redirigir.
curl se invoca con `--max-time 5` para no bloquear el loop si la conexion tarda. Los errores de curl (DNS, conexion rechazada) se tratan como "no listo" y el loop continua.
Salida de progreso va a stderr para no contaminar pipelines que capturen stdout.
---
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# wait_for_http — polling HTTP hasta 200/2xx o timeout
wait_for_http() {
local url="${1:-}"
local timeout="${2:-30}"
local interval="${3:-1}"
# Validar dependencia
if ! command -v curl &>/dev/null; then
echo "wait_for_http: curl no encontrado" >&2
return 5
fi
# Validar URL
if [[ -z "$url" || ( "$url" != http://* && "$url" != https://* ) ]]; then
echo "wait_for_http: URL invalida — debe empezar por http:// o https://" >&2
return 2
fi
local start
start=$(date +%s)
local last_code="none"
while true; do
local now
now=$(date +%s)
local elapsed=$(( now - start ))
if (( elapsed >= timeout )); then
echo "TIMEOUT $url after ${timeout}s, last code: $last_code" >&2
return 1
fi
local code
code=$(curl -fsS -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || true)
last_code="${code:-000}"
echo ". waiting $url code=$last_code elapsed=${elapsed}s" >&2
# 2xx => OK
if [[ "$last_code" =~ ^2[0-9]{2}$ ]]; then
local finish
finish=$(date +%s)
local total=$(( finish - start ))
echo "OK $url (${total}s)"
return 0
fi
sleep "$interval"
done
}
+65
View File
@@ -0,0 +1,65 @@
---
name: wait_for_port
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wait_for_port(host: string, port: int, timeout_seconds: int, interval_seconds: int) -> int"
description: "Hace polling TCP a host:puerto hasta que acepte conexiones o agote el timeout. Util para esperar a que un servicio (DB, API, container) este listo antes de ejecutar pasos siguientes."
tags: [tcp, wait, poll, port]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: host
desc: "Hostname, IP o DNS del servicio a esperar (ej: localhost, 192.168.1.10, db.internal)"
- name: port
desc: "Puerto TCP a sondear (1-65535)"
- name: timeout_seconds
desc: "Tiempo maximo de espera en segundos antes de abortar. Default 30"
- name: interval_seconds
desc: "Intervalo en segundos entre intentos de conexion. Default 1"
output: "Exit 0 e imprime 'OK host:port (Ns)' en stdout cuando el puerto acepta conexiones. Exit 1 con mensaje TIMEOUT en stderr si se agota el tiempo. Exit 2 si el puerto es invalido. Exit 3 si el host esta vacio."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wait_for_port.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wait_for_port.sh
# Esperar PostgreSQL con timeout de 60s
wait_for_port localhost 5432 60
# Esperar Redis con intervalo de 2s
wait_for_port 192.168.1.10 6379 30 2
# Uso en pipeline de deploy
wait_for_port localhost 8080 45 && curl http://localhost:8080/health
```
## Notas
Detecta al inicio si `nc` esta disponible. Si no, usa el builtin `/dev/tcp` de bash (disponible en bash >= 3.0, no requiere herramientas externas).
Metodos en orden de prioridad:
1. `nc -z -w 2 <host> <port>` — netcat con timeout de 2s por intento
2. `exec 3<>/dev/tcp/<host>/<port>` — bash builtin TCP, no requiere nc
Cada intento fallido imprime `. waiting host:port elapsed=Ns` en stderr para visibilidad en logs de CI/CD.
Complementa `wait_for_http_bash_infra` (HTTP/HTTPS): esta funcion opera a nivel TCP puro, util cuando el servicio no expone HTTP o cuando se quiere verificar la capa de red antes de la aplicacion.
Codigos de salida:
- 0: puerto disponible
- 1: timeout agotado
- 2: puerto invalido (no numerico o fuera de 1-65535)
- 3: host vacio
---
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# wait_for_port — polling TCP a host:puerto hasta conexion o timeout
wait_for_port() {
local host="$1"
local port="$2"
local timeout="${3:-30}"
local interval="${4:-1}"
# Validaciones
if [[ -z "$host" ]]; then
echo "wait_for_port: host vacio" >&2
return 3
fi
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "wait_for_port: puerto invalido '$port'" >&2
return 2
fi
# Detectar metodo disponible una sola vez
local method
if command -v nc &>/dev/null; then
method="nc"
else
method="devtcp"
fi
local _try_connect
if [[ "$method" == "nc" ]]; then
_try_connect() { nc -z -w 2 "$host" "$port" &>/dev/null; }
else
_try_connect() {
(exec 3<>/dev/tcp/"$host"/"$port") &>/dev/null
}
fi
local start elapsed
start=$(date +%s)
while true; do
elapsed=$(( $(date +%s) - start ))
if (( elapsed >= timeout )); then
echo "TIMEOUT ${host}:${port} after ${timeout}s" >&2
return 1
fi
echo ". waiting ${host}:${port} elapsed=${elapsed}s" >&2
if _try_connect; then
echo "OK ${host}:${port} (${elapsed}s)"
return 0
fi
sleep "$interval"
done
}
+73
View File
@@ -0,0 +1,73 @@
---
name: backup_all
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "backup_all(backup_root: string) -> void"
description: "Backup completo del estado del registry: snapshot atomico de registry.db, snapshot de cada operations.db de cada app, y rsync de todos los vaults declarados en vault.yaml. Aplica retention 7/4/12 (daily/weekly/monthly) con rotate_backups. Idempotente, llamable a diario desde cron o systemd-timer."
tags: ["backup", "launcher", "pipeline", "retention"]
uses_functions:
- backup_sqlite_db_bash_infra
- rotate_backups_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: backup_root
desc: "Directorio raiz donde se guardan todos los backups (ej. ~/backups/fn_registry). Se crea si no existe."
output: "Linea de resumen a stdout: ISO_timestamp, bytes de registry.db, conteo de ops backupeadas, conteo de vaults sincronizados, errores parciales y segundos transcurridos. Misma linea se hace append en backup_root/backup_log.txt."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/backup_all.sh"
---
## Ejemplo
```bash
# Backup manual a ~/backups/fn_registry
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
backup_all ~/backups/fn_registry
# Salida esperada:
# 2026-05-07T10:30:00+02:00 registry=4194304B ops=3 vaults=2 partial_errors=0 elapsed=12s
# Entrada en crontab (diario a las 02:00)
# 0 2 * * * FN_REGISTRY_ROOT=/home/lucas/fn_registry bash /home/lucas/fn_registry/bash/functions/pipelines/backup_all.sh ~/backups/fn_registry
```
## Estructura de backup_root/
```
registry/
daily.0 daily.1 ... daily.6
weekly.0 ... weekly.3
monthly.0 ... monthly.11
operations/
<app_name>/
daily.0 ... (misma retencion)
vaults/
<vault_name>/
daily.0/ (directorio rsync con hard-links)
daily.1/ ...
backup_log.txt
```
## Codigos de salida
| Codigo | Significado |
|--------|-------------|
| 0 | Exito completo |
| 1 | FN_REGISTRY_ROOT no localizable |
| 2 | backup_root no se puede crear/escribir |
| 3 | Fallo critico en backup de registry.db |
| 4 | Errores parciales en ops o vaults (no critico, continua) |
| 5 | Herramientas del sistema faltantes (sqlite3, rsync, find) |
## Notas
Idempotente: llamar multiples veces el mismo dia solo rota si hay cambios de fecha. Los vaults deducan bloques iguales con rsync --link-dest, por lo que el primer run ocupa el espacio real y los sucesivos solo almacenan los deltas. Operations.db de apps sin dicho archivo se ignoran silenciosamente. Requiere FN_REGISTRY_ROOT seteado o ejecutar desde la raiz del registry.
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# backup_all — Backup completo del estado del registry: registry.db, operations.db de cada app, y vaults declarados.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../infra/backup_sqlite_db.sh"
source "$SCRIPT_DIR/../infra/rotate_backups.sh"
backup_all() {
local backup_root="${1:?Arg 1 requerido: backup_root}"
local start_ts
start_ts=$(date +%s)
# --- 1. Localizar FN_REGISTRY_ROOT ---
local registry_root
if [[ -n "${FN_REGISTRY_ROOT:-}" && -f "$FN_REGISTRY_ROOT/registry.db" ]]; then
registry_root="$FN_REGISTRY_ROOT"
elif [[ -f "$(pwd)/registry.db" ]]; then
registry_root="$(pwd)"
else
echo "ERROR: No se puede localizar registry.db. Setea FN_REGISTRY_ROOT o ejecuta desde la raiz del registry." >&2
return 1
fi
# --- 2. Verificar herramientas del sistema ---
local missing_tools=()
for tool in sqlite3 rsync find; do
command -v "$tool" &>/dev/null || missing_tools+=("$tool")
done
if [[ ${#missing_tools[@]} -gt 0 ]]; then
echo "ERROR: Herramientas faltantes: ${missing_tools[*]}" >&2
return 5
fi
# --- 3. Crear backup_root ---
if ! mkdir -p "$backup_root/registry" "$backup_root/operations" "$backup_root/vaults"; then
echo "ERROR: No se puede crear/escribir en $backup_root" >&2
return 2
fi
local log_file="$backup_root/backup_log.txt"
local iso_ts
iso_ts=$(date --iso-8601=seconds)
local ops_count=0
local vaults_count=0
local registry_bytes=0
local partial_errors=0
# --- 4. Backup registry.db ---
local snap_registry="/tmp/registry-snap-$$.db"
if ! backup_sqlite_db "$registry_root/registry.db" "$snap_registry"; then
echo "ERROR critico: Fallo snapshot de registry.db" >&2
rm -f "$snap_registry"
return 3
fi
registry_bytes=$(stat -c%s "$snap_registry" 2>/dev/null || echo 0)
rotate_backups "$backup_root/registry" "$snap_registry" 7 4 12
rm -f "$snap_registry"
# --- 5. Backup operations.db de cada app ---
while IFS= read -r ops_db; do
local app_dir
app_dir="$(dirname "$ops_db")"
local app_name
app_name="$(basename "$app_dir")"
local snap_ops="/tmp/ops-snap-$$-${app_name}.db"
if backup_sqlite_db "$ops_db" "$snap_ops"; then
rotate_backups "$backup_root/operations/$app_name" "$snap_ops" 7 4 12 || ((partial_errors++))
rm -f "$snap_ops"
((ops_count++))
else
echo "WARN: Fallo snapshot de $ops_db — skipped" >&2
rm -f "$snap_ops"
((partial_errors++))
fi
done < <(find "$registry_root/apps" "$registry_root/projects" -name "operations.db" -maxdepth 4 2>/dev/null || true)
# --- 6. Backup vaults via rsync + link-dest ---
local vault_yaml
while IFS= read -r vault_yaml; do
if [[ ! -f "$vault_yaml" ]]; then continue; fi
# Parsear entradas de vault.yaml: buscar pares name/path
local current_name=""
while IFS= read -r line; do
if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*name:[[:space:]]*(.+)$ ]]; then
current_name="${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^[[:space:]]*path:[[:space:]]*(.+)$ && -n "$current_name" ]]; then
local vault_path="${BASH_REMATCH[1]}"
# Expandir ~ si fuera necesario
vault_path="${vault_path/#\~/$HOME}"
if [[ ! -d "$vault_path" ]]; then
echo "WARN: Vault '$current_name' path '$vault_path' no existe — skipped" >&2
((partial_errors++))
current_name=""
continue
fi
local vault_dest="$backup_root/vaults/$current_name"
mkdir -p "$vault_dest"
local link_dest="$vault_dest/daily.1"
local tmp_dest="$vault_dest/daily.0.tmp"
rm -rf "$tmp_dest"
if [[ -d "$link_dest" ]]; then
rsync -a --link-dest="$link_dest" "$vault_path/" "$tmp_dest/"
else
rsync -a "$vault_path/" "$tmp_dest/"
fi
# Rotacion manual de directorios (7 daily, 4 weekly, 12 monthly)
_rotate_vault_dirs "$vault_dest" 7 4 12
mv "$tmp_dest" "$vault_dest/daily.0"
((vaults_count++))
current_name=""
fi
done < "$vault_yaml"
done < <(find "$registry_root/projects" -name "vault.yaml" -maxdepth 4 2>/dev/null || true)
# --- 7. Log y stdout ---
local end_ts elapsed
end_ts=$(date +%s)
elapsed=$(( end_ts - start_ts ))
local summary="${iso_ts} registry=${registry_bytes}B ops=${ops_count} vaults=${vaults_count} partial_errors=${partial_errors} elapsed=${elapsed}s"
echo "$summary" >> "$log_file"
echo "$summary"
if [[ $partial_errors -gt 0 ]]; then
echo "WARN: $partial_errors errores parciales (no criticos). Ver $log_file" >&2
return 4
fi
return 0
}
# Rotacion manual de directorios vault (mismo algoritmo que rotate_backups pero sobre dirs)
_rotate_vault_dirs() {
local dir="$1"
local daily="${2:-7}"
local weekly="${3:-4}"
local monthly="${4:-12}"
# Promover a monthly (del weekly.0 al ultimo monthly)
local week_day
week_day=$(date +%u) # 1=lunes..7=domingo
local month_day
month_day=$(date +%d)
if [[ "$month_day" == "01" && -d "$dir/weekly.0" ]]; then
for ((i=monthly-1; i>=1; i--)); do
[[ -d "$dir/monthly.$((i-1))" ]] && mv "$dir/monthly.$((i-1))" "$dir/monthly.$i"
done
[[ -d "$dir/weekly.0" ]] && cp -al "$dir/weekly.0" "$dir/monthly.0"
fi
if [[ "$week_day" == "7" && -d "$dir/daily.0" ]]; then
for ((i=weekly-1; i>=1; i--)); do
[[ -d "$dir/weekly.$((i-1))" ]] && mv "$dir/weekly.$((i-1))" "$dir/weekly.$i"
done
[[ -d "$dir/daily.0" ]] && cp -al "$dir/daily.0" "$dir/weekly.0"
fi
# Rotar daily
[[ -d "$dir/daily.$((daily-1))" ]] && rm -rf "$dir/daily.$((daily-1))"
for ((i=daily-1; i>=1; i--)); do
[[ -d "$dir/daily.$((i-1))" ]] && mv "$dir/daily.$((i-1))" "$dir/daily.$i"
done
}
backup_all "$@"
@@ -0,0 +1,60 @@
---
name: compile_cpp_app
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "compile_cpp_app(app_name?: string) -> void"
description: "Pipeline que resuelve la app C++ desde el nombre o CWD, la cross-compila para Windows con mingw-w64, y despliega el .exe al escritorio de Windows. Composicion de resolve_cpp_app_dir + build_cpp_windows + deploy_cpp_exe_to_windows."
tags: [cpp, compile, windows, mingw, cross-compile, deploy, pipeline]
uses_functions:
- resolve_cpp_app_dir_bash_infra
- build_cpp_windows_bash_infra
- deploy_cpp_exe_to_windows_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/compile_cpp_app.sh"
params:
- name: app_name
desc: "Nombre de la app a compilar (opcional). Sin arg se deduce desde el directorio actual si estamos dentro de cpp/apps/<X>/ o projects/*/apps/<X>/."
output: "Compila el .exe y lo despliega al escritorio de Windows. Imprime progreso por steps a stderr y resumen final con ls -lh del .exe resultante."
---
## Ejemplo
```bash
# Desde dentro del directorio de la app (sin arg)
cd /home/lucas/fn_registry/cpp/apps/chart_demo
fn run compile_cpp_app
# Con nombre explicito desde cualquier directorio
fn run compile_cpp_app registry_dashboard
# Directo
bash bash/functions/pipelines/compile_cpp_app.sh graph_explorer
```
## Flujo
1. `resolve_cpp_app_dir` — deduce nombre y directorio absoluto de la app (desde CWD o arg)
2. Verifica que existe `CMakeLists.txt` en el directorio de la app
3. `build_cpp_windows` — cross-compila con mingw-w64 solo el target de la app
4. `deploy_cpp_exe_to_windows` — copia exe, DLLs, assets, enrichers y runtime al escritorio de Windows
5. Imprime `ls -lh` del exe final en Desktop/apps/<APP>/
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
## Notas
Reemplaza la logica del slash command `/compile`. No lleva tag `launcher` porque no es un pipeline TUI-lanzable (tarda minutos en compilar).
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Pipeline: compile_cpp_app — Resuelve la app, la cross-compila para Windows y despliega al escritorio.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/../infra"
source "$INFRA_DIR/resolve_cpp_app_dir.sh"
source "$INFRA_DIR/build_cpp_windows.sh"
source "$INFRA_DIR/deploy_cpp_exe_to_windows.sh"
compile_cpp_app() {
local app_arg="${1:-}"
# --- Paso 1: Resolver nombre y directorio de la app ---
echo "[1/3] Resolviendo app..." >&2
local resolved
resolved=$(resolve_cpp_app_dir "$app_arg")
local APP APP_DIR
APP="$(echo "$resolved" | cut -f1)"
APP_DIR="$(echo "$resolved" | cut -f2)"
echo " App: $APP" >&2
echo " Dir: $APP_DIR" >&2
# --- Verificar que tiene CMakeLists.txt ---
if [ ! -f "$APP_DIR/CMakeLists.txt" ]; then
echo "ERROR: $APP_DIR/CMakeLists.txt no encontrado." >&2
echo "La app '$APP' no esta registrada con CMake. Ver cpp_apps.md §5." >&2
return 1
fi
# --- Paso 2: Cross-compilar para Windows ---
echo "" >&2
echo "[2/3] Compilando '$APP' para Windows (mingw-w64)..." >&2
build_cpp_windows "$APP"
# --- Paso 3: Desplegar al escritorio de Windows ---
echo "" >&2
echo "[3/3] Desplegando '$APP' al escritorio de Windows..." >&2
deploy_cpp_exe_to_windows "$APP" "$APP_DIR"
# --- Resumen final ---
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
local final_exe="$win_desktop_apps/$APP/$APP.exe"
echo "" >&2
if [ -f "$final_exe" ]; then
echo "===== compile_cpp_app: OK =====" >&2
ls -lh "$final_exe" >&2
else
echo "WARN: no se encuentra $final_exe" >&2
fi
}
compile_cpp_app "$@"
+54
View File
@@ -0,0 +1,54 @@
---
name: full_git_pull
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "full_git_pull() -> stdout: tabla resumen"
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
tags: [git, pull, sync, registry, pipeline]
uses_functions:
- discover_git_repos_bash_infra
- git_pull_with_stash_bash_infra
- pass_get_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params: []
output: "tabla resumen por stdout: pull status de cada repo, estado de pass-secrets, submodulos actualizados, resultado de fn index, resultado de fn sync; lista de repos con divergencia o conflicto de stash al final"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/full_git_pull.sh"
---
## Ejemplo
```bash
# Pull completo
fn run full_git_pull
# Directo
bash bash/functions/pipelines/full_git_pull.sh
```
## Flujo
1. `discover_git_repos` — lista repos git locales bajo `$FN_REGISTRY_ROOT`
2. `git_pull_with_stash` — para cada repo: stash si dirty, fetch, pull --ff-only, pop stash
3. `git submodule update --init --recursive` — actualiza submodulos del repo principal
4. `git_pull_with_stash` sobre `~/.password-store` (si existe)
5. `CGO_ENABLED=1 ./fn index` — regenera registry.db
6. `fn sync` — sincroniza proposals, apps, projects, analysis, vaults, pc_locations
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_API`, `REGISTRY_API_TOKEN` — se cargan de `pass registry/*`
## Notas
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# Pipeline: full_git_pull — Pull automatico de fn_registry + sub-repos + submodules + fn sync
# Descubre repos locales, stashea dirty trees, hace pull --ff-only, actualiza submodules,
# regenera registry.db y ejecuta fn sync.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/../infra"
source "$INFRA_DIR/discover_git_repos.sh"
source "$INFRA_DIR/git_pull_with_stash.sh"
source "$INFRA_DIR/pass_get.sh"
full_git_pull() {
# Resolver raiz del registry
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
cd "$registry_root"
echo "=== full_git_pull: inicio ===" >&2
echo "Registry root: $registry_root" >&2
# --- Paso 1: Descubrir repos ---
echo "" >&2
echo "[1/5] Descubriendo repos git..." >&2
local repos
repos=$(discover_git_repos "$registry_root")
local n_repos
n_repos=$(echo "$repos" | grep -c . || true)
echo " Encontrados: $n_repos repos" >&2
# --- Paso 2: Pull de cada repo ---
echo "" >&2
echo "[2/5] Pullando repos..." >&2
local pull_summary=""
local diverged=()
local conflicts=()
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
local result
result=$(git_pull_with_stash "$repo" 2>/dev/null || true)
if [[ -n "$result" ]]; then
echo " $result" >&2
pull_summary="$pull_summary"$'\n'" $result"
if [[ "$result" == "[diverged]"* ]]; then
diverged+=("$repo")
elif [[ "$result" == "[stash-conflict]"* ]]; then
conflicts+=("$repo")
fi
fi
done <<< "$repos"
# --- Paso 3: Submodules del repo principal ---
echo "" >&2
echo "[3/5] Actualizando submodulos del repo principal..." >&2
local submodule_summary=" [skip] sin submodulos"
if [[ -f "$registry_root/.gitmodules" ]]; then
local sub_out
sub_out=$(git -C "$registry_root" submodule update --init --recursive 2>&1 | tail -10 || true)
echo "$sub_out" >&2
submodule_summary=" OK: $sub_out"
fi
# --- Paso 3b: Pull de ~/.password-store ---
echo "" >&2
echo "[3b] Pullando ~/.password-store..." >&2
local pass_dir="${PASSWORD_STORE_DIR:-$HOME/.password-store}"
local pass_summary=" [skip] password-store: no encontrado"
if [[ -d "$pass_dir/.git" ]]; then
local pass_result
pass_result=$(git_pull_with_stash "$pass_dir" 2>/dev/null || true)
echo " $pass_result" >&2
pass_summary=" $pass_result"
if [[ "$pass_result" == "[diverged]"* ]]; then
diverged+=("$pass_dir")
elif [[ "$pass_result" == "[stash-conflict]"* ]]; then
conflicts+=("$pass_dir")
fi
fi
# --- Paso 4: Regenerar registry.db ---
echo "" >&2
echo "[4/5] Regenerando registry.db..." >&2
local index_summary=" [skip] fn no encontrado"
local fn_bin="$registry_root/fn"
if [[ -x "$fn_bin" ]]; then
local index_out
index_out=$(CGO_ENABLED=1 "$fn_bin" index 2>&1 | tail -3 || true)
echo "$index_out" >&2
index_summary=" OK: $index_out"
else
echo " [warn] $fn_bin no encontrado — intentando build..." >&2
if command -v go >/dev/null 2>&1; then
CGO_ENABLED=1 go build -tags fts5 -o "$fn_bin" "$registry_root/cmd/fn/" 2>&1 >&2 || true
if [[ -x "$fn_bin" ]]; then
local index_out
index_out=$(CGO_ENABLED=1 "$fn_bin" index 2>&1 | tail -3 || true)
echo "$index_out" >&2
index_summary=" OK (post-build): $index_out"
fi
fi
fi
# --- Paso 5: fn sync ---
echo "" >&2
echo "[5/5] Ejecutando fn sync..." >&2
local sync_summary=" [skip] fn sync: credenciales no disponibles"
if [[ -x "$fn_bin" ]]; then
local api_user api_pass api_token
api_user=$(pass_get registry/basicauth-user | head -n1 2>/dev/null || true)
api_pass=$(pass_get registry/basicauth-pass | head -n1 2>/dev/null || true)
api_token=$(pass_get registry/api-token | head -n1 2>/dev/null || true)
if [[ -n "$api_user" && -n "$api_pass" && -n "$api_token" ]]; then
export FN_REGISTRY_API="https://${api_user}:${api_pass}@registry.organic-machine.com"
export REGISTRY_API_TOKEN="$api_token"
local sync_out
sync_out=$("$fn_bin" sync 2>&1) && {
sync_summary=" OK: $sync_out"
} || {
sync_summary=" [error] fn sync: $sync_out"
}
echo " $sync_summary" >&2
else
echo " [warn] Credenciales registry no disponibles — omitiendo fn sync" >&2
fi
fi
# --- Resumen ---
echo ""
echo "===== RESUMEN full_git_pull ====="
echo ""
echo "Pull status por repo:"
if [[ -n "$pull_summary" ]]; then
echo "$pull_summary"
else
echo " (ninguno)"
fi
echo ""
echo "pass-secrets:"
echo "$pass_summary"
echo ""
echo "Submodulos:"
echo "$submodule_summary"
echo ""
echo "fn index:"
echo "$index_summary"
echo ""
echo "fn sync:"
echo "$sync_summary"
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
echo ""
echo "ATENCION — Repos que requieren intervencion manual:"
for r in "${diverged[@]+"${diverged[@]}"}"; do
echo " [diverged] $r → git rebase o git merge manual"
done
for r in "${conflicts[@]+"${conflicts[@]}"}"; do
echo " [stash-conflict] $r → resolver conflicto y git stash drop"
done
fi
echo ""
echo "================================="
}
full_git_pull "$@"
+64
View File
@@ -0,0 +1,64 @@
---
name: full_git_push
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
tags: [git, push, sync, registry, pipeline]
uses_functions:
- discover_git_repos_bash_infra
- scan_secrets_in_dirty_bash_cybersecurity
- git_auto_commit_dirty_bash_infra
- git_push_if_ahead_bash_infra
- ensure_repo_synced_bash_infra
- pass_get_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: commit_message
desc: "mensaje de commit fijo para todos los repos (opcional); si se omite, cada repo recibe un mensaje generado automaticamente segun sus cambios"
output: "tabla resumen por stdout: commits creados por repo, push status de cada repo, estado de pass-secrets, resultado de fn sync"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/full_git_push.sh"
---
## Ejemplo
```bash
# Push con mensaje automatico
fn run full_git_push
# Push con mensaje fijo para todos los repos
fn run full_git_push "chore: sync desde home-wsl"
# Directo
bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
```
## Flujo
1. `discover_git_repos` — lista todos los repos bajo `$FN_REGISTRY_ROOT`
2. Auto-init — para cada app/analysis sin `.git`, llama `ensure_repo_synced` (requiere `GITEA_URL`/`GITEA_TOKEN` via `pass_get`)
3. `scan_secrets_in_dirty` — escanea cada repo; si hay matches **aborta todo** y lista los archivos
4. `git_auto_commit_dirty` — commitea dirty trees con mensaje fijo o generado
5. `git_push_if_ahead` — pushea solo repos con commits locales (sin tocar la red para los up-to-date)
6. Push de `~/.password-store` — solo push (sin commit; pass se autocommitea)
7. `fn sync` — sincroniza proposals, apps, projects, analysis, vaults, pc_locations con registry_api
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `GITEA_URL`, `GITEA_TOKEN` — se cargan de `pass agentes/gitea-url` y `pass gitea/dataforge-git-token`
- `FN_REGISTRY_API`, `REGISTRY_API_TOKEN` — se cargan de `pass registry/*`
## Notas
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Pipeline: full_git_push — Push automatico de fn_registry + todos los sub-repos + fn sync
# Descubre repos, escanea secrets, auto-commitea dirty trees, pushea solo los ahead,
# pushea ~/.password-store, y ejecuta fn sync para sincronizar metadata no regenerable.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/../infra"
CYBERSEC_DIR="$SCRIPT_DIR/../cybersecurity"
source "$INFRA_DIR/discover_git_repos.sh"
source "$INFRA_DIR/git_auto_commit_dirty.sh"
source "$INFRA_DIR/git_push_if_ahead.sh"
source "$INFRA_DIR/pass_get.sh"
source "$INFRA_DIR/ensure_repo_synced.sh"
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
full_git_push() {
local commit_message="${1:-}"
# Resolver raiz del registry
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
cd "$registry_root"
echo "=== full_git_push: inicio ===" >&2
echo "Registry root: $registry_root" >&2
# --- Paso 1: Descubrir repos ---
echo "" >&2
echo "[1/6] Descubriendo repos git..." >&2
local repos
repos=$(discover_git_repos "$registry_root")
# --- Paso 1b: Auto-inicializar apps/analyses sin .git ---
echo "" >&2
echo "[1b] Verificando apps/analyses sin git..." >&2
local gitea_url gitea_token
gitea_url=$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)
gitea_token=$(pass_get gitea/dataforge-git-token | head -n1 2>/dev/null || true)
if [[ -n "$gitea_url" && -n "$gitea_token" ]]; then
export GITEA_URL="$gitea_url"
export GITEA_TOKEN="$gitea_token"
export FN_REGISTRY_INFRA_DIR="$INFRA_DIR"
local missing_dirs=()
for pattern in "apps/*/" "analysis/*/" "projects/*/apps/*/" "projects/*/analysis/*/"; do
while IFS= read -r d; do
d="${d%/}"
if [[ -d "$d" && ! -d "$d/.git" ]]; then
missing_dirs+=("$d")
fi
done < <(find "$registry_root" -maxdepth 4 -type d -name "$(basename "$pattern")" 2>/dev/null | grep -E "$pattern" || true)
done
# Forma mas directa: iterar directorios conocidos
for pattern in apps analysis; do
if [[ -d "$registry_root/$pattern" ]]; then
for d in "$registry_root/$pattern"/*/; do
d="${d%/}"
[[ -d "$d" ]] || continue
[[ -d "$d/.git" ]] && continue
echo " auto-init: $d" >&2
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2
done
fi
done
for proj in "$registry_root"/projects/*/; do
for subdir in apps analysis; do
[[ -d "$proj$subdir" ]] || continue
for d in "$proj$subdir"/*/; do
d="${d%/}"
[[ -d "$d" ]] || continue
[[ -d "$d/.git" ]] && continue
echo " auto-init: $d" >&2
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2
done
done
done
else
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
fi
# Redescubrir repos tras posibles inicializaciones
repos=$(discover_git_repos "$registry_root")
# --- Paso 2: Escanear secrets ---
echo "" >&2
echo "[2/6] Escaneando secrets en dirty trees..." >&2
local secret_matches=""
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
local matches
matches=$(scan_secrets_in_dirty "$repo" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
secret_matches="$secret_matches"$'\n'"--- $repo ---"$'\n'"$matches"
fi
done <<< "$repos"
if [[ -n "$secret_matches" ]]; then
echo "" >&2
echo "ABORTANDO: archivos sospechosos detectados antes de commitear:" >&2
echo "$secret_matches" >&2
echo "" >&2
echo "Gestiona esos archivos (.gitignore, mover, o decidir si entran) y reintenta." >&2
return 1
fi
echo " OK: sin archivos sospechosos" >&2
# --- Paso 3: Auto-commitear dirty trees ---
echo "" >&2
echo "[3/6] Auto-commiteando dirty trees..." >&2
local commits_summary=""
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
local subject
subject=$(git_auto_commit_dirty "$repo" "$commit_message" 2>/dev/null || true)
if [[ -n "$subject" ]]; then
local repo_name
repo_name="$(basename "$repo")"
echo " commit: $repo_name$subject" >&2
commits_summary="$commits_summary"$'\n'" $repo_name: $subject"
fi
done <<< "$repos"
# --- Paso 4: Push de repos con commits locales ---
echo "" >&2
echo "[4/6] Pusheando repos adelantados..." >&2
local push_summary=""
while IFS= read -r repo; do
[[ -z "$repo" ]] && continue
local status_line
status_line=$(git_push_if_ahead "$repo" 2>/dev/null || true)
if [[ -n "$status_line" ]]; then
echo " $status_line" >&2
push_summary="$push_summary"$'\n'" $status_line"
fi
done <<< "$repos"
# --- Paso 5: Push de ~/.password-store (sin commitear) ---
echo "" >&2
echo "[5/6] Verificando ~/.password-store..." >&2
local pass_dir="${PASSWORD_STORE_DIR:-$HOME/.password-store}"
local pass_summary=" [skip] password-store: no encontrado"
if [[ -d "$pass_dir/.git" ]]; then
local pass_dirty
pass_dirty=$(git -C "$pass_dir" status --porcelain | wc -l)
if [[ "$pass_dirty" -gt 0 ]]; then
echo " [warn] ~/.password-store tiene cambios sin commitear; pass debe commitear solo. Saltando push." >&2
pass_summary=" [warn] password-store: dirty (pass no commiteo)"
else
local pass_status
pass_status=$(git_push_if_ahead "$pass_dir" 2>/dev/null || true)
echo " $pass_status" >&2
pass_summary=" $pass_status"
fi
fi
# --- Paso 6: fn sync ---
echo "" >&2
echo "[6/6] Ejecutando fn sync..." >&2
local sync_summary=" [skip] fn sync: credenciales no disponibles"
local fn_bin="$registry_root/fn"
if [[ -x "$fn_bin" ]]; then
local api_user api_pass api_token
api_user=$(pass_get registry/basicauth-user | head -n1 2>/dev/null || true)
api_pass=$(pass_get registry/basicauth-pass | head -n1 2>/dev/null || true)
api_token=$(pass_get registry/api-token | head -n1 2>/dev/null || true)
if [[ -n "$api_user" && -n "$api_pass" && -n "$api_token" ]]; then
export FN_REGISTRY_API="https://${api_user}:${api_pass}@registry.organic-machine.com"
export REGISTRY_API_TOKEN="$api_token"
local sync_out
sync_out=$("$fn_bin" sync 2>&1) && {
sync_summary=" OK: $sync_out"
} || {
sync_summary=" [error] fn sync: $sync_out"
}
echo " $sync_summary" >&2
else
echo " [warn] Credenciales registry no disponibles — omitiendo fn sync" >&2
fi
else
echo " [warn] $fn_bin no encontrado — omitiendo fn sync" >&2
fi
# --- Resumen ---
echo ""
echo "===== RESUMEN full_git_push ====="
echo ""
echo "Commits creados:"
if [[ -n "$commits_summary" ]]; then
echo "$commits_summary"
else
echo " (ninguno)"
fi
echo ""
echo "Push status:"
if [[ -n "$push_summary" ]]; then
echo "$push_summary"
else
echo " (ninguno)"
fi
echo ""
echo "pass-secrets:"
echo "$pass_summary"
echo ""
echo "fn sync:"
echo "$sync_summary"
echo ""
echo "================================="
}
full_git_push "$@"