fix(fn-run): propagar stdout/stderr de bash functions library-style #1

Open
dataforge wants to merge 537 commits from auto/0077-fn-run-bash-mudo into master
77 changed files with 6511 additions and 534 deletions
Showing only changes of commit 625569485f - Show all commits
+10
View File
@@ -145,6 +145,16 @@ fn show <id>
fn add -k function # Template
fn check params # Lista funciones sin params_schema
# Doctor: diagnostico read-only del registry y artefactos
fn doctor # Corre todos los checks
fn doctor artefacts # git/venv/app.md/upstream de cada app y analysis
fn doctor services # apps tag 'service' + systemctl + puerto
fn doctor sync # drift pc_locations BD vs disco
fn doctor uses-functions # imports reales vs uses_functions del app.md
fn doctor unused # funciones del registry sin consumidores
fn doctor --json # salida JSON (cualquier subcomando)
# Ver .claude/rules/fn_doctor.md para mapeo subcomando → funcion + acciones derivadas.
# Ejecutar funciones y pipelines (fn run)
fn run <id_or_name> [args...] # Ejecuta por ID o nombre
fn run init_metabase --project test # Go pipeline (go run .)
+26 -197
View File
@@ -1,208 +1,37 @@
# /compile — Compila la app actual y la copia al escritorio de Windows
# /compile — Compila app C++ y la copia al escritorio de Windows
Compila una app del registry para los targets que soporte (Windows via MinGW, Android via Gradle/NDK si esta configurado) y deja el resultado en `/mnt/c/Users/lucas/Desktop/apps/<app>/`, listo para usar desde Windows.
Wrapper sobre el pipeline `compile_cpp_app_bash_pipelines`. Toda la lógica vive en el registry (resolver app desde CWD/arg, cross-compile MinGW, copiar exe + DLLs + assets/ + enrichers/ + runtime/ a `/mnt/c/Users/lucas/Desktop/apps/<app>/`, taskkill previo, preservar `local_files/`).
Pensado para apps C++ del workspace `cpp/` (donde ya hay toolchain `mingw-w64.cmake` y build dir `cpp/build/windows/`). Si en el futuro hay apps Android (Gradle wrapper o NDK), tambien las detecta.
```bash
cd /home/lucas/fn_registry
./fn run compile_cpp_app "$ARGUMENTS"
```
## Argumento
`$ARGUMENTS` — opcional. Nombre de la app a compilar (ej: `chart_demo`, `registry_dashboard`).
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
- Sin argumento: detectar la app desde `pwd` (si estas dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`).
- Si no hay app deducible y no se pasa argumento listar apps disponibles y pedir nombre.
- Si se pasa argumento, usarlo directamente.
- Sin argumento: deduce desde `pwd` si estás dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`.
- Si no se puede deducir y no se pasa argumento, el pipeline lista las apps disponibles en stderr y aborta.
## Pasos
## Qué hace el pipeline
### 1. Resolver la app y su directorio fuente
```bash
ROOT=/home/lucas/fn_registry
APP_ARG="$ARGUMENTS"
# Detectar desde CWD si no hay argumento
if [ -z "$APP_ARG" ]; then
CWD="$(pwd)"
case "$CWD" in
"$ROOT"/cpp/apps/*|"$ROOT"/projects/*/apps/*)
APP_ARG="$(basename "$CWD")" ;;
esac
fi
# Si sigue vacio, listar apps y abortar
if [ -z "$APP_ARG" ]; then
echo "Apps disponibles:"
ls "$ROOT"/cpp/apps/ 2>/dev/null
ls "$ROOT"/projects/*/apps/ 2>/dev/null
echo "Uso: /compile <app_name>"
exit 1
fi
# Buscar el directorio real
APP_DIR=""
for cand in "$ROOT/cpp/apps/$APP_ARG" "$ROOT"/projects/*/apps/"$APP_ARG"; do
[ -d "$cand" ] && APP_DIR="$cand" && break
done
if [ -z "$APP_DIR" ]; then
echo "No encuentro app '$APP_ARG' en cpp/apps/ ni projects/*/apps/"
exit 1
fi
echo "App: $APP_ARG"
echo "Dir: $APP_DIR"
```
### 2. Detectar targets soportados
Examinar el `app.md` y los archivos del directorio para decidir que se puede compilar:
- **Windows (MinGW)**: si la app tiene `CMakeLists.txt` y se registra en `cpp/CMakeLists.txt` (es decir, aparece como subdirectorio en `cpp/build/windows/apps/<APP>/`). Default para apps C++.
- **Android**: si existe `AndroidManifest.xml`, `build.gradle`, `build.gradle.kts` o carpeta `android/` dentro de `$APP_DIR`. (Hoy no hay ninguna; saltar silenciosamente.)
- **Linux** (opcional, no por defecto): el build dir `cpp/build/` ya genera el binario para Linux. Solo se hace si el usuario lo pide explicitamente.
```bash
TARGETS=()
[ -f "$APP_DIR/CMakeLists.txt" ] && TARGETS+=("windows")
if [ -f "$APP_DIR/AndroidManifest.xml" ] || \
[ -f "$APP_DIR/build.gradle" ] || \
[ -f "$APP_DIR/build.gradle.kts" ] || \
[ -d "$APP_DIR/android" ]; then
TARGETS+=("android")
fi
if [ ${#TARGETS[@]} -eq 0 ]; then
echo "No se detecta ningun target compilable en $APP_DIR"
exit 1
fi
echo "Targets: ${TARGETS[*]}"
```
### 3. Compilar Windows (cross-compile MinGW)
Solo si `windows` esta en TARGETS.
```bash
BUILD_WIN="$ROOT/cpp/build/windows"
# Configurar build dir si no existe
if [ ! -f "$BUILD_WIN/CMakeCache.txt" ]; then
mkdir -p "$BUILD_WIN"
cmake -S "$ROOT/cpp" -B "$BUILD_WIN" \
-DCMAKE_TOOLCHAIN_FILE="$ROOT/cpp/toolchains/mingw-w64.cmake" \
-DCMAKE_BUILD_TYPE=Release
fi
# Compilar SOLO el target de la app (no todo el arbol)
cmake --build "$BUILD_WIN" --target "$APP_ARG" -j"$(nproc)"
```
Si el target no existe en CMake (porque la app no esta registrada en `cpp/CMakeLists.txt`), reportar y proponer registrarla siguiendo `cpp_apps.md` §5. NO autoregistrarla sin confirmacion del usuario.
### 4. Copiar a `/mnt/c/Users/lucas/Desktop/apps/<APP>/`
Layout estandar (convencion `assets/` + `local_files/`, ver `cpp_apps.md` §7):
```
Desktop/apps/<APP>/
├── <APP>.exe ← binario (top level por convencion Windows DLL)
├── *.dll ← DLLs nativas (Windows las busca junto al exe)
├── assets/ ← read-only, ships con el zip
│ ├── *.ttf ← fuentes (vienen de add_imgui_app)
│ ├── enrichers/ ← si <app_dir>/enrichers existe
│ ├── runtime/ ← Python embed si app.md tiene python_runtime: true
│ ├── gx-cli, gx-cli.exe ← si la app necesita un MCP server
│ └── ... ← cualquier otro asset distribuible
└── local_files/ ← writable, per-PC, creado por la app al
primer arranque. NUNCA borrar al recompilar.
```
```bash
DEST="/mnt/c/Users/lucas/Desktop/apps/$APP_ARG"
ASSETS="$DEST/assets"
mkdir -p "$DEST" "$ASSETS"
EXE_SRC="$BUILD_WIN/apps/$APP_ARG/$APP_ARG.exe"
if [ ! -f "$EXE_SRC" ]; then
echo "ERROR: no se ha generado $EXE_SRC"
exit 1
fi
# 1. Binario + DLLs en el top level (Windows DLL search convention).
cp -v "$EXE_SRC" "$DEST/"
find "$BUILD_WIN/apps/$APP_ARG" -maxdepth 1 -type f -name '*.dll' \
-exec cp -v {} "$DEST/" \;
# 2. assets/ — TTFs (las copia add_imgui_app a build/<app>/assets/) y
# cualquier asset extra del build (build/<app>/assets/*).
if [ -d "$BUILD_WIN/apps/$APP_ARG/assets" ]; then
rsync -a --delete "$BUILD_WIN/apps/$APP_ARG/assets/" "$ASSETS/"
fi
# 3. 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
# 4. runtime/ Python embebido -> assets/runtime/ (si la app lo declara).
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_ARG"
"$APP_DIR/tools/freeze_python_runtime.sh" "$APP_DIR" windows
fi
rsync -a --delete --exclude '__pycache__' --exclude '*.pyc' \
"$APP_DIR/runtime/" "$ASSETS/runtime/"
fi
# 5. Otros assets sueltos del app_dir (gx-cli, scripts varios). El
# convention es: si vive en <app_dir>/ y no es codigo fuente, va a
# assets/. Ahora mismo la unica excepcion es gx-cli (graph_explorer).
for extra in gx-cli gx-cli.exe; do
if [ -f "$APP_DIR/$extra" ]; then
cp -v "$APP_DIR/$extra" "$ASSETS/"
fi
done
# 6. NO TOCAR local_files/. Si existe en $DEST, preservar — contiene
# estado del usuario (DBs, settings, layouts ImGui, proyectos).
echo "OK: $APP_ARG -> $DEST"
[ -d "$DEST/local_files" ] && echo " local_files/ preservado: $(du -sh "$DEST/local_files" | cut -f1)"
```
### 5. Compilar Android (solo si TARGETS contiene `android`)
Hoy no hay apps Android en el registry, asi que esta rama no se ejecuta. Cuando se anada la primera, este es el patron previsto:
```bash
if [[ " ${TARGETS[*]} " == *" android "* ]]; then
ANDROID_DIR="$APP_DIR"
[ -d "$APP_DIR/android" ] && ANDROID_DIR="$APP_DIR/android"
cd "$ANDROID_DIR"
if [ -x "./gradlew" ]; then
./gradlew assembleRelease
APK="$(find "$ANDROID_DIR/app/build/outputs/apk/release" -name '*.apk' | head -n1)"
[ -n "$APK" ] && cp -v "$APK" "/mnt/c/Users/lucas/Desktop/apps/$APP_ARG/"
else
echo "android: no hay ./gradlew en $ANDROID_DIR — saltando."
fi
fi
```
Cuando llegue la primera app Android, este bloque puede ampliarse (firma, ABI splits, etc.).
### 6. Resumen
Imprime al final una linea por target con:
- Tamano del binario (`ls -lh`)
- Path final en `/mnt/c/Users/lucas/Desktop/apps/<APP>/`
- Nombre del exe/apk
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>` desde arg o CWD.
2. Verifica `CMakeLists.txt` en el dir resuelto.
3. `build_cpp_windows_bash_infra <app>` — cross-compila el target específico con `cpp/build/windows/` (configura toolchain `mingw-w64.cmake` la primera vez).
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
- `taskkill.exe /IM <app>.exe /F` (pre-autorizado).
- Copia `<app>.exe` + DLLs al top-level de `Desktop/apps/<app>/`.
- rsync `cpp/build/windows/apps/<app>/assets/``Desktop/apps/<app>/assets/`.
- rsync `<app_dir>/enrichers/``assets/enrichers/` si existe.
- Si `app.md` declara `python_runtime: true`, regenera `runtime/` con `tools/freeze_python_runtime.sh` y rsync a `assets/runtime/`.
- Copia `gx-cli`/`gx-cli.exe` si existen.
- **NUNCA** toca `local_files/` (estado del usuario).
5. Imprime `ls -lh` del `.exe` final.
## Notas
- El build de Windows usa `cpp/build/windows/` (no `cpp/build/`). El de Linux es `cpp/build/`. Coexisten sin conflicto.
- El toolchain `mingw-w64.cmake` ya configura linkado estatico (`-static-libgcc -static-libstdc++ -lwinpthread`) — el `.exe` resultante es self-contained y no necesita DLLs en el escritorio.
- Si se pasa una app que vive en `projects/<proj>/apps/<APP>/`, el target CMake sigue siendo `<APP>` (registrado en `cpp/CMakeLists.txt` con `add_subdirectory(${PROJ_DIR}/apps/<APP> ${CMAKE_BINARY_DIR}/apps/<APP>)`).
- NO tocar `AdminLocal` ni instalar nada en `Program Files` — solo el escritorio del usuario.
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
- Si la app no está registrada en `cpp/CMakeLists.txt`, `cmake --build --target <app>` falla. Registrar siguiendo `.claude/rules/cpp_apps.md` §5.
- Para tocar la lógica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,deploy_cpp_exe_to_windows,compile_cpp_app}.sh`, no este wrapper.
+23 -45
View File
@@ -1,63 +1,37 @@
# /entrada_diario — Añadir entrada al diario del día
Añade una entrada nueva a `docs/diary/YYYY-MM-DD.md` con la fecha y hora actuales. Si el archivo del día no existe, lo crea con el encabezado del día. Si existe, **añade** una sección nueva al final (nunca sobrescribe ni reescribe entradas previas).
Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja todo el manejo de archivos (crear `docs/diary/YYYY-MM-DD.md` si no existe, append seguro, formato exacto). Este comando solo decide el contenido.
## Uso
```
/entrada_diario <descripción o resumen del bloque de trabajo>
/entrada_diario <descripción del bloque de trabajo>
/entrada_diario # sin args → resume sesión actual
```
Si no se pasa argumento, resume la sesión actual de forma concisa (qué hicimos, qué completamos, qué queda pendiente).
## Pasos del asistente
## Pasos que debe seguir el asistente
1. **Componer `TITULO` (corto, una linea) y `CUERPO`** (viñetas markdown):
- Con `$ARGUMENTS`: derivar `TITULO` directo del argumento; `CUERPO` con viñetas concretas (`- Hecho:`, `- Pendiente:`).
- Sin `$ARGUMENTS`: revisar TaskList + `git log --since=today` + `git status` y resumir en 3-5 viñetas.
1. **Fecha y hora**:
2. **Llamar la función del registry**:
```bash
DATE=$(date +%Y-%m-%d)
TIME=$(date +%H:%M)
cd /home/lucas/fn_registry
source bash/functions/infra/append_diary_entry.sh
append_diary_entry "<TITULO>" "$(cat <<'EOF'
<CUERPO>
EOF
)"
```
2. **Ruta del archivo del día**: `docs/diary/${DATE}.md`
3. **Si el archivo NO existe**, crearlo con:
```markdown
# ${DATE}
```
4. **Componer la entrada** en este formato exacto:
```markdown
## ${TIME} — <título corto derivado del argumento>
<1-3 líneas de contexto breve si aplica>
- Hecho: <viñeta concreta>
- Hecho: <viñeta concreta>
- Pendiente: <viñeta si procede>
<Referencias opcionales: commit SHAs cortos, ADR #NNNN, issue #N, rutas a funciones del registry>
```
5. **Añadir al final del archivo** (nunca editar secciones anteriores). Usar `Write` con el contenido completo si es el primer uso del día, `Edit` para append en días ya empezados.
La función imprime el path del archivo escrito.
## Reglas de estilo
- **Viñetas breves**, no párrafos. Si un punto necesita explicación larga, probablemente es un ADR en lugar de un diario.
- **Verbos en pasado para lo hecho**, infinitivo para lo pendiente.
- **Enlaces a artefactos**: commits (`SHA` corto de 7-8 chars), ADRs (`[0001](../adr/0001-...)`), funciones del registry por ID.
- **No duplicar con CHANGELOG**: el diario es contexto operativo ("qué hice hoy"), el CHANGELOG es "qué cambió cara al usuario".
- Si el argumento es vacío, revisar TaskList + cambios en git (`git log --since=today`, `git status`) y resumir en 3-5 viñetas.
## Ejemplos
```
/entrada_diario cerrado issue #23 del dashboard, fix en http_client.cpp
```
```
/entrada_diario # sin args → resume sesión
```
- Viñetas breves, no párrafos. Verbos en pasado para lo hecho, infinitivo para pendientes.
- Enlaces a artefactos: commits (SHA corto 7-8 chars), ADRs (`[0001](../adr/0001-...)`), funciones del registry por ID.
- No duplicar con CHANGELOG: el diario es contexto operativo ("qué hice hoy"), el CHANGELOG es "qué cambió cara al usuario".
- NUNCA editar secciones anteriores. La función solo append.
## Relación con otras formas de registro
@@ -67,3 +41,7 @@ Si no se pasa argumento, resume la sesión actual de forma concisa (qué hicimos
| Qué cambió en el código (cara usuario/agentes) | Editar `CHANGELOG.md` directamente |
| Por qué tomamos una decisión arquitectural | Nuevo ADR en `docs/adr/NNNN-*.md` |
| Una regla operativa nueva del registry | Nuevo archivo en `.claude/rules/` + entrada en INDEX.md |
## Para tocar la lógica
Editar la función `append_diary_entry_bash_infra` en el registry, no este wrapper.
+14 -110
View File
@@ -1,124 +1,28 @@
# /full-git-pull — Pull automático de fn_registry + sub-repos + submodules + fn sync
Trae los últimos cambios del remote para el repo principal `fn_registry`, todos los sub-repos git anidados que **ya existan localmente**, y los submodules de `cpp/vendor/`. Después regenera `registry.db` y corre `fn sync` para tirar de la metadata del `registry_api`.
**Modo automático (preferencia del usuario):** este comando NO pregunta. Auto-stashea dirty trees antes de pullear y hace `pop` después. Sigue con el resto de repos aunque uno falle. Solo se detiene si detecta riesgo serio (conflicto en stash pop que requiere intervención humana).
**No clona repos que falten.** Cada PC tiene solo el subset de apps/analyses que le interesa. Si en este PC necesitas un sub-repo que aún no tienes, clónalo a mano:
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
```bash
git clone https://<user>:<token>@<gitea-host>/dataforge/<name>.git <path>
cd /home/lucas/fn_registry
./fn run full_git_pull_bash_pipelines
```
Consulta `pc_locations` para ver dónde lo tiene otro PC y reproduce el path.
## Argumento
`$ARGUMENTS` — sin uso, ignorar.
## Pasos
## Qué hace el pipeline
### 1. Descubrir repos locales
```bash
cd /home/lucas/fn_registry
REPOS=$(find . -name ".git" -type d \
-not -path "./.git" -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 \
| sed 's|/.git$||')
REPOS=". $REPOS"
```
Solo se actualizan los sub-repos que ya tengan `.git/` localmente.
### 2. Para cada repo: stash si dirty, pull --ff-only, pop
```bash
for r in $REPOS; do
( cd "$r" \
&& DIRTY=$(git status --porcelain | wc -l) \
&& if [ "$DIRTY" -gt 0 ]; then
git stash push -m "auto-stash before /full-git-pull" --include-untracked >/dev/null
STASHED=1
else
STASHED=0
fi \
&& git fetch origin 2>&1 | tail -1 \
&& git pull --ff-only 2>&1 | tail -3 \
&& if [ "$STASHED" = "1" ]; then
git stash pop 2>&1 | tail -3
fi
)
done
```
- Si `--ff-only` falla por divergencia → reportar ese repo, seguir con el resto. **No** rebasear ni mergear.
- Si `stash pop` produce conflictos → **avisar al usuario al final** y dejar el conflicto sin tocar; seguir con los demás repos.
### 3. Submodules del repo principal
```bash
git submodule update --init --recursive 2>&1 | tail -10
```
### 4. Regenerar registry.db local
```bash
CGO_ENABLED=1 ./fn index 2>&1 | tail -3
```
### 5. Pull del repo de pass (`~/.password-store`)
El password store es su propio repo Git en Gitea (`dataforge/pass-secrets`). `pass insert/edit/rm` commitea automaticamente, asi que aqui SOLO hay que pullear los commits remotos.
```bash
PASS_DIR="$HOME/.password-store"
if [ -d "$PASS_DIR/.git" ]; then
( cd "$PASS_DIR" \
&& DIRTY=$(git status --porcelain | wc -l) \
&& if [ "$DIRTY" -gt 0 ]; then
git stash push -m "auto-stash before /full-git-pull" --include-untracked >/dev/null
STASHED=1
else
STASHED=0
fi \
&& git fetch origin 2>&1 | tail -1 \
&& git pull --ff-only 2>&1 | tail -3 \
&& if [ "$STASHED" = "1" ]; then
git stash pop 2>&1 | tail -3
fi
)
fi
```
Reglas:
- Mismo patron que el resto de repos: stash → fetch → pull --ff-only → pop.
- Si `--ff-only` falla por divergencia, reportar y seguir; no resolver a mano.
- Si `stash pop` da conflicto, avisar al final.
### 6. fn sync
```bash
USER=$(pass registry/basicauth-user | head -1)
PASSWD=$(pass registry/basicauth-pass | head -1)
TOKEN=$(pass registry/api-token | head -1)
export FN_REGISTRY_API="https://${USER}:${PASSWD}@registry.organic-machine.com"
export REGISTRY_API_TOKEN="$TOKEN"
./fn sync
```
Si `pass` falla → gpg-agent bloqueado, pedir al usuario `pass show unlock` en su terminal real (entrada dummy que devuelve "Desbloqueada!" sin exponer API keys).
### 7. Resumen
Tabla concisa: por repo, commits pulleados o "ya estaba al día"; estado de `pass-secrets`; submodules actualizados; resultado de `fn index`; resultado de `fn sync`. Si algún repo quedó con conflicto de stash o divergencia, listarlos al final con la acción sugerida.
1. `discover_git_repos_bash_infra` — lista repos locales (mismas exclusiones que push).
2. `git_pull_with_stash_bash_infra` por repo: stash si dirty → fetch → pull --ff-only → pop. Estados posibles por repo: `[pulled]`, `[up-to-date]`, `[diverged]`, `[stash-conflict]`.
3. `git submodule update --init --recursive` en root.
4. `git_pull_with_stash` sobre `~/.password-store`.
5. `CGO_ENABLED=1 ./fn index` para regenerar `registry.db`.
6. `./fn sync` con credenciales de `pass`.
## Notas
- **Modo no-interactivo por diseño.** El usuario prefiere flujos rápidos sin confirmaciones.
- Pull solo es fast-forward — nunca rebase ni merge automático.
- Auto-stash incluye untracked (`--include-untracked`) para no perder archivos nuevos.
- `fn index` se corre **antes** de `fn sync` para que las locations locales reflejen el estado actual.
- **Modo no-interactivo.** Auto-stash con `--include-untracked`.
- **Solo fast-forward.** Nunca rebase ni merge automático. Si un repo diverge, se reporta y sigue con el resto.
- **No clona repos faltantes.** Cada PC tiene su subset. Para añadir uno, clonarlo a mano y mirar `pc_locations` para reproducir el path.
- Para tocar la lógica: editar las funciones del registry, no este wrapper.
+17 -178
View File
@@ -1,190 +1,29 @@
# /full-git-push — Push automático de fn_registry + todos los sub-repos + fn sync
# /full-git-push — Push automático de fn_registry + sub-repos + fn sync
Pushea el repo principal `fn_registry` y todos los sub-repos git anidados (apps y analyses, cada uno como repo independiente bajo `dataforge/<name>` en Gitea), y luego ejecuta `fn sync` para empujar la metadata no regenerable (proposals, apps, projects, analysis, vaults, pc_locations) al `registry_api`.
**Estandar:** todo `apps/<name>`, `analysis/<name>`, `projects/*/apps/<name>` y `projects/*/analysis/<name>` debe tener su propio repo Gitea bajo `dataforge/<basename>`. Los `subrepos/` de la raiz NO entran (mirrors upstream). Los `vaults/` tampoco.
**Modo automático (preferencia del usuario):** este comando NO pregunta. Si hay dirty trees, commitea automáticamente con un mensaje generado a partir de los cambios. Prioridad: hacer commits frecuentes y pushear rápido. **Único límite:** no commitear archivos que parezcan secrets (`.env`, `*credentials*`, `*.key`, `*.pem`, `id_rsa*`) — si se detectan, abortar y avisar.
## Argumento
`$ARGUMENTS` — opcional. Si se pasa texto, se usa como mensaje de commit. Sin argumento, se genera uno automáticamente con el patrón:
```
chore: auto-commit (<N> archivos modificados, <N> nuevos, <N> borrados)
- <ruta1>
- <ruta2>
...
```
Si los cambios tienen un patrón claro (todos en un mismo dominio/dir), usar ese patrón en el subject:
- todo bajo `python/functions/<dom>/``feat(<dom>): auto-commit con N cambios`
- todo bajo `dev/issues/``chore(issues): auto-commit`
- mezclado → `chore: auto-commit`
## Pasos
### 1. Descubrir repos git + apps/analyses sin git
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
```bash
cd /home/lucas/fn_registry
REPOS=$(find . -name ".git" -type d \
-not -path "./.git" -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 \
| sed 's|/.git$||')
REPOS=". $REPOS"
# Apps/analyses sin .git — auto-inicializar como dataforge/<basename>
MISSING=()
for d in apps/*/ analysis/*/ projects/*/apps/*/ projects/*/analysis/*/; do
d="${d%/}"
[[ -d "$d/.git" ]] || MISSING+=("$d")
done
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
```
### 1b. Auto-inicializar repos faltantes (sin pedir confirmación)
## Argumento
Para cada `$d` en `MISSING`:
`$ARGUMENTS` — opcional. Mensaje de commit fijo para todos los repos dirty. Sin argumento, el pipeline genera un mensaje automático por repo según los paths cambiados (ver `bash/functions/infra/git_auto_commit_dirty.sh`).
```bash
export GITEA_URL=$(pass agentes/gitea-url | head -n1)
export GITEA_TOKEN=$(pass gitea/dataforge-git-token | head -n1)
export FN_REGISTRY_INFRA_DIR=/home/lucas/fn_registry/bash/functions/infra
## Qué hace el pipeline
bash -c "
source $FN_REGISTRY_INFRA_DIR/ensure_repo_synced.sh
ensure_repo_synced '$d' dataforge \"\$(basename '$d')\" master 'chore: initial sync'
"
```
Si `$d/.gitignore` no existe antes de inicializar, escribir uno apropiado (ver `.claude/rules/apps_vs_functions.md`). Solo abortar la inicialización de ese repo concreto si falla; seguir con el resto.
### 2. Detectar secrets antes de commitear
Para cada repo dirty, listar archivos modificados/nuevos y comprobar nombres sospechosos:
```bash
for r in $REPOS; do
( cd "$r" \
&& git status --porcelain | awk '{print $2}' \
| grep -E '(^|/)(\.env(\..*)?$|.*credentials.*|.*\.key$|.*\.pem$|id_rsa.*|.*secret.*|.*token.*\.txt$)' \
| head -5
)
done
```
Si la lista de coincidencias **no está vacía**, abortar el push completo, listar los archivos sospechosos y pedir al usuario que los gestione (añadir a `.gitignore`, mover, o decidir explícitamente que entren).
### 3. Auto-commitear dirty trees
Para cada repo con cambios sin commitear:
```bash
for r in $REPOS; do
( cd "$r"
[ -z "$(git status --porcelain)" ] && exit 0 # limpio, nada que hacer
git add -A
if [ -n "$ARGUMENTS" ]; then
MSG="$ARGUMENTS"
else
MSG="$(generate_auto_message)" # patrón descrito en sección 'Argumento'
fi
git commit -m "$MSG" \
-m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" 2>&1 | tail -3
)
done
```
`generate_auto_message` debe inspeccionar `git diff --cached --stat` y producir un subject como `feat(notebook): N cambios` cuando todos los paths comparten prefijo, o `chore: auto-commit` si están dispersos.
### 4. Push solo de repos con cambios locales (NO usar `git push` ciego)
**Filtrar primero, pushear despues.** Sobre 30+ repos, hacer `git push` en todos (incluso "Everything up-to-date") cuesta 30-90s en handshakes SSH. Usar refs locales (sin red) para decidir si hay algo que pushear:
```bash
for r in $REPOS; do
( cd "$r" \
&& BRANCH=$(git rev-parse --abbrev-ref HEAD) \
&& UPSTREAM_OK=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null) \
&& if [ -z "$UPSTREAM_OK" ]; then
echo "[push -u] $r ($BRANCH)"
git push -u origin "$BRANCH" 2>&1 | tail -3
else
AHEAD=$(git rev-list --count @{u}..HEAD 2>/dev/null)
if [ "${AHEAD:-0}" -gt 0 ]; then
echo "[push] $r ($BRANCH, $AHEAD commits ahead)"
git push origin "$BRANCH" 2>&1 | tail -3
else
echo "[skip] $r (up-to-date local refs)"
fi
fi
)
done
```
Reglas:
- Sin upstream → `git push -u` (siempre).
- Con upstream y `rev-list @{u}..HEAD` > 0 → push.
- Con upstream y 0 ahead → skip (ni siquiera intentar).
`rev-list @{u}..HEAD` solo lee refs locales, no toca la red. Esto es seguro porque cualquier commit local hecho en este PC ya esta a partir del paso 3 (auto-commit). Si en otro PC se hizo push y aqui no se ha hecho pull, sigue siendo correcto: no tenemos nada local que pushear.
Si `push` rechaza por non-fast-forward (rama behind), no abortar el resto. Reportar ese repo concreto y sugerir `/full-git-pull` antes; seguir con los demás repos.
### 5. Push del repo de pass (`~/.password-store`)
El password store es su propio repo Git en Gitea (`dataforge/pass-secrets`). `pass insert/edit/rm` ya commitea automaticamente, por lo que aqui SOLO hay que pushear los commits locales.
```bash
PASS_DIR="$HOME/.password-store"
if [ -d "$PASS_DIR/.git" ]; then
( cd "$PASS_DIR" \
&& DIRTY=$(git status --porcelain | wc -l) \
&& if [ "$DIRTY" -gt 0 ]; then
echo "[warn] $PASS_DIR tiene cambios sin commitear; pass deberia commitear solo. Saltando push."
else
BRANCH=$(git rev-parse --abbrev-ref HEAD) \
&& AHEAD=$(git rev-list --count @{u}..HEAD 2>/dev/null) \
&& if [ "${AHEAD:-0}" -gt 0 ]; then
echo "[push] pass-secrets ($BRANCH, $AHEAD commits ahead)"
git push origin "$BRANCH" 2>&1 | tail -3
else
echo "[skip] pass-secrets (up-to-date)"
fi
fi
)
fi
```
Reglas:
- NO hacer `git add` ni `git commit` en `~/.password-store` (pass lo gestiona). Si hay dirty tree, avisar y saltar.
- NO escanear `*.gpg` por patrones de secrets — son secrets cifrados, su contenido es opaco.
- El push usa el remote ya configurado (`dataforge/pass-secrets` en Gitea).
### 6. fn sync
```bash
USER=$(pass registry/basicauth-user | head -1)
PASSWD=$(pass registry/basicauth-pass | head -1)
TOKEN=$(pass registry/api-token | head -1)
export FN_REGISTRY_API="https://${USER}:${PASSWD}@registry.organic-machine.com"
export REGISTRY_API_TOKEN="$TOKEN"
./fn sync
```
Si `pass` falla con "decryption failed" → gpg-agent bloqueado. Pedir al usuario que ejecute `pass show unlock` en su terminal real (Bash tool no tiene TTY) y reintentar. Esa entrada es un dummy que solo muestra `Desbloqueada!` para cachear el passphrase sin exponer ninguna API key.
### 7. Resumen
Tabla concisa: por repo, commits creados (cuántos y subject), commits pusheados, o "ya estaba al día". Estado de `pass-secrets` (pushed / skipped / dirty). Y resultado de `fn sync` (sent / received / imported).
1. `discover_git_repos_bash_infra` — lista repos bajo `fn_registry` (excluye `node_modules`, `.venv`, `cpp/vendor`, `cpp/build`, `sources`, `temp`, `subrepos`).
2. Auto-inicializa apps/analyses sin `.git` con `ensure_repo_synced_bash_infra` (Gitea `dataforge/<basename>`).
3. `scan_secrets_in_dirty_bash_cybersecurity` — aborta si detecta nombres sospechosos (`.env*`, `*credentials*`, `*.key`, `*.pem`, `id_rsa*`, `*secret*`, `*token*.txt`).
4. `git_auto_commit_dirty_bash_infra` — commitea cada repo dirty.
5. `git_push_if_ahead_bash_infra` — push solo si `rev-list @{u}..HEAD > 0` (sin red previa).
6. Push de `~/.password-store` (sin commitear, pass autocommitea).
7. `./fn sync` con credenciales cargadas desde `pass`.
## Notas
- **Modo no-interactivo por diseño.** El usuario prefiere commits frecuentes y push rápido. No se pregunta si commitear ni se pide mensaje (salvo que se pase via `$ARGUMENTS`).
- **Secrets son la única razón para abortar antes de commitear.** Cualquier patrón sospechoso (`.env`, credenciales, claves) detiene el flujo y se reporta al usuario.
- Submodules `cpp/vendor/` (mirrors upstream) se ignoran.
- Si un sub-repo va `behind` el remote, su push se omite con un mensaje (no se aborta el resto). El usuario corre `/full-git-pull` cuando le convenga.
- **Modo no-interactivo por diseño.** Auto-commitea sin preguntar.
- **Único motivo de aborto antes de commitear:** secret detectado por nombre.
- Si un sub-repo va `behind` el remote, su push se omite (no aborta el resto). Correr `/full-git-pull` y reintentar.
- Para tocar la lógica: editar las funciones del registry, no este wrapper.
+1
View File
@@ -26,3 +26,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
| 21 | [playgrounds.md](playgrounds.md) | Prototipos rapidos dentro de un artefacto padre — heredan entorno, no se indexan, no tienen repo propio |
| 22 | [registry_first.md](registry_first.md) | Antes de escribir codigo en un artefacto: buscar en el registry, reutilizar si existe, delegar a `fn-constructor` si falta |
| 23 | [fn_doctor.md](fn_doctor.md) | `fn doctor`: diagnostico read-only de artefactos, services, sync drift, uses_functions, unused — wrappers de funciones del registry |
+72
View File
@@ -0,0 +1,72 @@
## fn doctor: diagnostico del registry y artefactos
`fn doctor` es el entrypoint unico para auditar la salud del sistema de forma read-only. Compone funciones del registry (`functions/infra/`) y formatea su salida. No modifica nada.
### Cuando usar
- Despues de un deploy: confirmar que servicios siguen vivos y artefactos intactos.
- Despues de `git pull` o `fn sync`: detectar drift entre BD y disco.
- Antes de `fn index` masivo: confirmar que apps Go/Py siguen declarando bien sus deps.
- Periodicamente (cron): listar funciones del registry sin consumidores para limpiar.
- Como gate antes de crear proposals: si `fn doctor` esta verde, las metricas del bucle reactivo son fiables.
### Comandos
```bash
fn doctor # Corre TODOS los checks (artefacts + services + sync + uses-functions + unused)
fn doctor artefacts # Solo artefactos: git/venv/app.md/upstream
fn doctor services # Solo apps con tag 'service' + systemctl + puerto
fn doctor sync # Solo drift pc_locations BD vs disco local
fn doctor uses-functions # Solo audit imports reales vs uses_functions
fn doctor unused # Solo funciones huerfanas del registry
fn doctor --json # Salida JSON (cualquier subcomando) — para agentes/scripts
```
### Mapeo subcomando → funcion del registry
| Subcomando | Funcion |
|---|---|
| `artefacts` | `artefact_doctor_go_infra` |
| `services` | `services_status_go_infra` |
| `sync` | `pc_locations_drift_go_infra` |
| `uses-functions` | `audit_uses_functions_go_infra` |
| `unused` | `find_unused_functions_go_infra` |
Cada subcomando es un wrapper fino. Toda la logica vive en la funcion. Si quieres usar la salida en otro programa Go, importa la funcion directamente.
### Salida
Texto humano por defecto (tabwriter). `--json` produce array/objeto serializable para `jq`, agentes o pipes.
### Idempotente y seguro
- Read-only: ningun subcomando escribe, mata procesos ni cambia estado.
- `services` abre conexiones TCP a `127.0.0.1:<port>` con timeout 500ms — no genera trafico saliente.
- `artefacts` ejecuta `git rev-parse @{u}` con timeout 3s por artefacto.
### Acciones complementarias (NO son `fn doctor`)
`fn doctor` solo diagnostica. Las acciones derivadas son verbos separados:
| Si `fn doctor` reporta... | Accion |
|---|---|
| `directory_missing` | Marcar `pc_locations.status='missing'` o re-clonar via `/full-git-pull` |
| `git_not_initialized` | `gitea_create_repo_bash_infra` + `ensure_repo_synced_bash_infra` |
| `venv_broken_path` | `cd <analysis_dir> && rm -rf .venv && uv sync` |
| `service active=inactive` | `systemctl --user start <unit>` o investigar logs |
| `port not listening` | `port_kill_bash_infra <port>` (si zombie) y relanzar |
| `missing_in_app_md` | Editar `app.md` y añadir el ID a `uses_functions` |
| `unused` (funcion huerfana) | Decidir: usar, deprecar (tag), o borrar |
| Backup viejo | `backup_all_bash_pipelines ~/backups/fn_registry` |
### Para agentes
Patron recomendado tras una accion no trivial (deploy, sync, mass edit):
```bash
fn doctor --json > /tmp/doctor.json
# Agente parsea JSON, decide si crear proposals o avisar al humano
```
Si el agente quiere actuar sobre los hallazgos, abre proposals con `fn proposal add` referenciando los IDs afectados — NO toca artefactos directamente sin aprobacion humana.
+36
View File
@@ -8,6 +8,42 @@ Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones ar
## [Unreleased]
## 2026-05-07
### Added
- **`fn doctor` CLI** (`cmd/fn/doctor.go`) — entrypoint unico read-only para diagnostico del registry y artefactos. Subcomandos: `artefacts` (git/venv/app.md/upstream), `services` (apps tag service + systemctl + puerto), `sync` (drift `pc_locations` BD vs disco), `uses-functions` (imports reales vs declarados en `app.md`), `unused` (funciones sin consumidores). Flag `--json` para agentes/scripts. Cada subcomando es wrapper fino sobre una funcion del registry.
- `.claude/rules/fn_doctor.md` — regla 23 en `INDEX.md`. Documenta cuando usar, mapeo subcomando → funcion del registry, y acciones derivadas (que hacer cuando reporta un drift).
- `bash/functions/infra/backup_sqlite_db` (`backup_sqlite_db_bash_infra`, **impure**) — snapshot atomico de SQLite via `VACUUM INTO`. Mas seguro que `cp` con escrituras concurrentes.
- `bash/functions/infra/rotate_backups` (`rotate_backups_bash_infra`, **impure**) — retention rsnapshot-style `daily.N/weekly.M/monthly.K`.
- `bash/functions/infra/wait_for_http` (`wait_for_http_bash_infra`, **impure**) — poll URL hasta 2xx con timeout, util en deploys/smoke tests.
- `bash/functions/infra/wait_for_port` (`wait_for_port_bash_infra`, **impure**) — poll TCP host:puerto. Usa `nc` o `/dev/tcp` builtin (sin deps).
- `bash/functions/infra/port_kill` (`port_kill_bash_infra`, **impure**) — mata proceso(s) escuchando un puerto. Idempotente, fallback `KILL` tras `TERM`.
- `bash/functions/infra/tail_journal` (`tail_journal_bash_infra`, **impure**) — wrapper `journalctl` con auto-deteccion `--user` vs sistema, prioridad y `--since`.
- `bash/functions/infra/pre_commit_hook_install` (`pre_commit_hook_install_bash_infra`, **impure**) — instala hook que llama `scan_secrets_in_dirty_bash_cybersecurity` antes de cada commit. Idempotente con marca `fn_registry-pre-commit-v1`.
- `functions/infra/notify_telegram` (`notify_telegram_go_infra`, **impure**) — envia mensaje a chat Telegram via Bot API. Trunca >4096 chars.
- `functions/infra/artefact_doctor` (`artefact_doctor_go_infra`, **impure**) — audita salud de cada app/analysis: dir existe, `.git` presente, manifest parseable, `.venv` valido (analyses), upstream configurado.
- `functions/infra/services_status` (`services_status_go_infra`, **impure**) — apps con tag `service` + `systemctl is-active` (user/system) + puerto declarado en notes/description + check TCP localhost.
- `functions/infra/pc_locations_drift` (`pc_locations_drift_go_infra`, **impure**) — detecta drift `pc_locations` BD vs disco para el PC actual (`~/.fn_pc`). Tres tipos: `missing_on_disk`, `untracked_on_disk`, `status_should_be_active`.
- `functions/infra/audit_uses_functions` (`audit_uses_functions_go_infra`, **impure**) — para cada app Go/Py compara imports reales contra `uses_functions` del `app.md`. Reporta `missing_in_app_md` y `unused_in_app_md`. Heuristica documentada (puede dar falsos positivos en `unused`).
- `functions/infra/find_unused_functions` (`find_unused_functions_go_infra`, **impure**) — funciones del registry sin consumidores en otras funciones, apps o analyses. Pipelines sin tag `launcher` tambien aparecen.
- `bash/functions/pipelines/backup_all` (`backup_all_bash_pipelines`, **impure**, tag `launcher`) — orquesta `backup_sqlite_db` + `rotate_backups` sobre `registry.db`, cada `apps/*/operations.db`, y rsync `--link-dest` para vaults declarados en `projects/*/vaults/vault.yaml`.
### Changed
- `.claude/CLAUDE.md` — seccion CLI ampliada con comandos `fn doctor [subcommand] [--json]` y enlace a la regla.
- `.claude/rules/INDEX.md` — anadida fila 23 para `fn_doctor.md`.
### Fixed
- `functions/infra/pc_locations_drift.go``filepath.Join(absoluto, absoluto)` producia paths corruptos cuando `dir_path` ya era absoluto (caso comun: filas `pc_locations` traen path absoluto al disco del PC). Fix: chequear `filepath.IsAbs` antes de unir. Sintoma previo: todos los artefactos reportados como `missing_on_disk` aunque existieran.
- `go.mod``golang.org/x/net` movido a deps directas (`go mod tidy` tras anadir `notify_telegram`).
### Notes
- Hallazgo de la primera ejecucion `fn doctor uses-functions`: 7/12 apps con drift real (`auto_metabase`, `dag_engine`, `deploy_server`, `docker_tui`, `kanban`, `metabase_registry`, `script_navegador`). Pendiente sincronizar sus `app.md` con los imports reales en sesion futura.
- `fn doctor unused` muestra muchas funciones core sin consumidores aun (`compose2_go_core`, `curry2_go_core`, etc.). Esperado: el registry crece antes que las apps que las consuman.
## 2026-05-04
### Added
@@ -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 "$@"
+253
View File
@@ -0,0 +1,253 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"fn-registry/functions/infra"
)
func cmdDoctor(args []string) {
jsonOut := false
sub := ""
for _, a := range args {
switch a {
case "--json":
jsonOut = true
case "-h", "--help":
doctorUsage()
return
default:
if sub == "" {
sub = a
}
}
}
r := root()
switch sub {
case "", "all":
doctorAll(r, jsonOut)
case "artefacts":
doctorArtefacts(r, jsonOut)
case "services":
doctorServices(r, jsonOut)
case "sync":
doctorSync(r, jsonOut)
case "uses-functions":
doctorUsesFunctions(r, jsonOut)
case "unused":
doctorUnused(r, jsonOut)
default:
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
doctorUsage()
os.Exit(1)
}
}
func doctorUsage() {
fmt.Println(`fn doctor — diagnostico read-only del registry y artefactos
Usage:
fn doctor [subcommand] [--json]
Subcommands:
(none)|all Corre todos los checks
artefacts Salud de apps y analyses (git, venv, app.md, upstream)
services Estado de apps con tag 'service' (systemd + puerto)
sync Drift entre pc_locations BD y disco
uses-functions Audit imports reales vs uses_functions del app.md
unused Funciones del registry sin consumidores
Flags:
--json Salida JSON (para scripting/agentes)`)
}
func doctorAll(root string, jsonOut bool) {
if jsonOut {
all := map[string]any{}
if v, err := infra.ArtefactDoctor(root); err == nil {
all["artefacts"] = v
} else {
all["artefacts_error"] = err.Error()
}
if v, err := infra.ServicesStatus(root); err == nil {
all["services"] = v
} else {
all["services_error"] = err.Error()
}
if v, err := infra.PcLocationsDrift(root, ""); err == nil {
all["sync"] = v
} else {
all["sync_error"] = err.Error()
}
if v, err := infra.AuditUsesFunctions(root); err == nil {
all["uses_functions"] = v
} else {
all["uses_functions_error"] = err.Error()
}
if v, err := infra.FindUnusedFunctions(root); err == nil {
all["unused"] = v
} else {
all["unused_error"] = err.Error()
}
emit(all)
return
}
fmt.Println("=== Artefacts ===")
doctorArtefacts(root, false)
fmt.Println("\n=== Services ===")
doctorServices(root, false)
fmt.Println("\n=== Sync (pc_locations drift) ===")
doctorSync(root, false)
fmt.Println("\n=== uses_functions audit ===")
doctorUsesFunctions(root, false)
fmt.Println("\n=== Unused functions ===")
doctorUnused(root, false)
}
func doctorArtefacts(root string, jsonOut bool) {
checks, err := infra.ArtefactDoctor(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(checks)
return
}
bad := 0
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "STATUS\tTYPE\tID\tISSUES")
for _, c := range checks {
status := "OK"
issues := "-"
if !c.OK {
status = "FAIL"
issues = strings.Join(c.Issues, "; ")
bad++
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, c.Type, c.ID, issues)
}
w.Flush()
fmt.Printf("\n%d/%d artefacts healthy.\n", len(checks)-bad, len(checks))
}
func doctorServices(root string, jsonOut bool) {
statuses, err := infra.ServicesStatus(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(statuses)
return
}
if len(statuses) == 0 {
fmt.Println("No services registered (no apps with tag 'service').")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tUNIT\tACTIVE\tPORT\tLISTENING")
for _, s := range statuses {
port := "-"
listen := "-"
if s.Port > 0 {
port = fmt.Sprintf("%d", s.Port)
listen = fmt.Sprintf("%v", s.PortListening)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, s.UnitName, s.UnitActive, port, listen)
}
w.Flush()
}
func doctorSync(root string, jsonOut bool) {
drifts, err := infra.PcLocationsDrift(root, "")
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(drifts)
return
}
if len(drifts) == 0 {
fmt.Println("No drift detected: pc_locations matches disk.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "TYPE\tID\tDIR\tSTATUS\tISSUE")
for _, d := range drifts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.EntityType, d.EntityID, d.DirPath, d.Status, d.Issue)
}
w.Flush()
fmt.Printf("\n%d drift(s) detected.\n", len(drifts))
}
func doctorUsesFunctions(root string, jsonOut bool) {
audits, err := infra.AuditUsesFunctions(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(audits)
return
}
bad := 0
for _, a := range audits {
if len(a.Missing) == 0 && len(a.Unused) == 0 {
continue
}
bad++
fmt.Printf("\n%s (%s)\n", a.AppID, a.Lang)
if len(a.Missing) > 0 {
fmt.Printf(" missing in app.md: %s\n", strings.Join(a.Missing, ", "))
}
if len(a.Unused) > 0 {
fmt.Printf(" declared but unused: %s\n", strings.Join(a.Unused, ", "))
}
}
if bad == 0 {
fmt.Println("All apps have matching uses_functions vs imports.")
} else {
fmt.Printf("\n%d/%d apps have drift.\n", bad, len(audits))
}
}
func doctorUnused(root string, jsonOut bool) {
unused, err := infra.FindUnusedFunctions(root)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if jsonOut {
emit(unused)
return
}
if len(unused) == 0 {
fmt.Println("No unused functions.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tAGE_DAYS")
for _, u := range unused {
fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", u.ID, u.Lang, u.Domain, u.AgeDays)
}
w.Flush()
fmt.Printf("\n%d unused functions (candidates to remove).\n", len(unused))
}
func emit(v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "json error: %v\n", err)
os.Exit(1)
}
fmt.Println(string(b))
}
+5 -1
View File
@@ -45,6 +45,8 @@ func main() {
cmdAnalysis(os.Args[2:])
case "sync":
cmdSync(os.Args[2:])
case "doctor":
cmdDoctor(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
@@ -70,7 +72,9 @@ Usage:
fn project <init|list|show|status> Gestiona proyectos
fn app <list|clone|pull> Gestiona apps externas (Gitea)
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)
fn sync [status|locations] Sincroniza con servidor central`)
fn sync [status|locations] Sincroniza con servidor central
fn doctor [artefacts|services|sync|uses-functions|unused] [--json]
Diagnostico read-only del registry`)
}
func root() string {
+16
View File
@@ -119,6 +119,22 @@ El agente consulta la operacional para:
---
## Capa diagnostica: `fn doctor`
Sobre el modelo registry+operations existe el comando `fn doctor` (read-only) que reporta estado del sistema:
- `artefacts` — salud por artefacto (git/venv/manifest/upstream).
- `services` — apps tag `service` + systemctl + puerto.
- `sync` — drift `pc_locations` BD vs disco del PC actual.
- `uses-functions` — drift entre imports reales en codigo de apps y `uses_functions` declarado en `app.md`.
- `unused` — funciones del registry sin consumidores.
Cada subcomando es wrapper fino sobre una funcion del registry (`functions/infra/{artefact_doctor,services_status,pc_locations_drift,audit_uses_functions,find_unused_functions}.go`). La logica vive en el registry; el CLI solo formatea. `--json` produce salida estructurada para agentes. Detalle en `.claude/rules/fn_doctor.md`.
Util tras deploys, `fn sync`, `git pull` masivos o como gate antes de evaluar metricas del bucle reactivo.
---
## Mejoras incorporadas al schema v1.0
| Problema | Solución |
+65
View File
@@ -0,0 +1,65 @@
# 2026-05-07
## 01:39 — `fn doctor` + 14 funciones nuevas para gestion del sistema
Sesion completa: del analisis de huecos del registry a CLI funcionando + diagnostico real ejecutando.
### Hecho
- **Razonamiento gap analysis** del registry actual (1051 funciones, 18 apps, 9 analyses): identificadas 10 categorias de funcionalidad ausente — backup/snapshot, doctor/health, secrets, cleanup/orphans, notify, lifecycle, ops cross-app, test coverage, sync verification, utils chicos.
- **Plan en 5 fases** con priorizacion top-5: backup, doctor, audit_uses_functions, notify_telegram, wait_for_http.
- **Fase 1 — 8 funciones bash/go base**: `backup_sqlite_db`, `rotate_backups`, `wait_for_http`, `wait_for_port`, `port_kill`, `tail_journal`, `pre_commit_hook_install`, `notify_telegram_go_infra`. Todas creadas via `fn-constructor` en paralelo (4+4).
- **Fase 2 — 5 funciones diagnostico Go en `functions/infra/`**: `artefact_doctor`, `services_status`, `pc_locations_drift`, `audit_uses_functions`, `find_unused_functions`. Tests pasan. 5 paralelos.
- **Fase 3 — pipeline `backup_all_bash_pipelines`** (tag `launcher`): orquesta backups de `registry.db` + cada `operations.db` + vaults con `rsync --link-dest`.
- **Fase 4 — `fn doctor` CLI** (`cmd/fn/doctor.go`): subcomandos `artefacts|services|sync|uses-functions|unused|all`, flag `--json`. Wrapper fino sobre las 5 funciones de Fase 2. Registrado en `cmd/fn/main.go` y `printUsage`.
- **Documentacion**: `.claude/rules/fn_doctor.md` (regla 23 en INDEX), seccion CLI en `.claude/CLAUDE.md`, capa diagnostica en `docs/architecture.md`, entrada `2026-05-07` en `CHANGELOG.md`.
### Bug encontrado y arreglado
- `pc_locations_drift_go_infra`: `filepath.Join(absoluto, absoluto)` → path corrupto tipo `/home/lucas/fn_registry/home/lucas/fn_registry/...`. Sintoma: TODOS los artefactos reportados como `missing_on_disk`. Fix: chequear `filepath.IsAbs` antes de unir (`pc_locations_drift.go:79` y `:135`).
- `go.mod`: `golang.org/x/net` movido a deps directas via `go mod tidy` tras anadir `notify_telegram` (transitiva promovida).
### Hallazgos de la primera ejecucion
- `fn doctor artefacts` → 25/27 OK. Falla `chart_demo_cpp_viz` y `shaders_lab_cpp_gfx` por `git_not_initialized` (sub-repos no clonados en este PC, esperado).
- `fn doctor services` → 8 services registrados, solo `sqlite_api.service` activo en este PC (puerto 8484 listening).
- `fn doctor sync` → sin drift tras el fix.
- `fn doctor uses-functions`**drift real en 7/12 apps**: `auto_metabase`, `dag_engine`, `deploy_server`, `docker_tui`, `kanban`, `metabase_registry`, `script_navegador`. Apps declaran funciones que no usan O usan funciones no declaradas en `app.md`. Pendiente sincronizar.
- `fn doctor unused` → muchas funciones core (`compose2`, `curry2`, `chunk`, `flat_map_slice`, etc.) sin consumidores aun. Esperado mientras el registry crece antes que las apps.
### Comandos clave
```bash
# Build
CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
# Verificar todo
./fn index
./fn doctor # texto humano
./fn doctor --json > out.json # para agentes
./fn doctor uses-functions # ver drift de imports
./fn doctor unused # funciones huerfanas
# Backup completo (cron-friendly)
bash bash/functions/pipelines/backup_all.sh ~/backups/fn_registry
```
### Numeros
- 1051 → 1065 funciones tras `fn index` (14 nuevas).
- 19 apps, 9 analyses, 4 projects, 3 vaults sin cambios.
- `fn doctor uses-functions` deja mapeada deuda de sincronizacion en 7 apps.
### Lo siguiente que pega
- Sincronizar `uses_functions` en los 7 `app.md` con drift detectado por `fn doctor uses-functions`.
- Decidir limpieza de funciones core sin consumidores (`fn doctor unused`): mantener (futuro), tag `deprecated`, o borrar.
- Cron diario de `backup_all` apuntando a `~/backups/fn_registry`.
- Considerar `notify_telegram` integrado en `deploy_server` para alertas de fallos.
- Extender `fn doctor` con subcomando `secrets` que invoque `scan_secrets_in_dirty` sobre todos los repos (apps + analyses) en una pasada.
### Archivos tocados
- Nuevos: 14 pares `.sh/.go + .md` en `bash/functions/{infra,pipelines}/` y `functions/infra/`. `cmd/fn/doctor.go`. `.claude/rules/fn_doctor.md`. `docs/diary/2026-05-07.md`.
- Modificados: `cmd/fn/main.go` (case `doctor`), `.claude/CLAUDE.md`, `.claude/rules/INDEX.md`, `docs/architecture.md`, `CHANGELOG.md`, `go.mod`, `go.sum`.
- Sin commit: 74 archivos en git status pendientes de revision por humano.
+172
View File
@@ -0,0 +1,172 @@
package infra
import (
"bufio"
"bytes"
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// ArtefactCheck holds the health report for a single artefact (app or analysis).
type ArtefactCheck struct {
ID string // registry id, e.g. "kanban_go_tools"
Type string // "app" or "analysis"
DirPath string // dir_path as stored in registry.db
Issues []string // human-readable problems; empty means healthy
OK bool // true when len(Issues) == 0
}
// ArtefactDoctor audits every app and analysis registered in registry.db.
// It checks disk presence, git initialisation, manifest parseability, venv
// health (analyses only) and upstream branch configuration.
// The function is read-only: it never modifies any file or database.
// Returns an error only if registry.db cannot be opened.
func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error) {
dbPath := filepath.Join(registryRoot, "registry.db")
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("artefact_doctor: open db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("artefact_doctor: ping db: %w", err)
}
var checks []ArtefactCheck
rows, err := db.Query("SELECT id, dir_path FROM apps")
if err != nil {
return nil, fmt.Errorf("artefact_doctor: query apps: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, dirPath string
if err := rows.Scan(&id, &dirPath); err != nil {
continue
}
checks = append(checks, checkArtefact(id, "app", dirPath, registryRoot))
}
rows2, err := db.Query("SELECT id, dir_path FROM analysis")
if err != nil {
return nil, fmt.Errorf("artefact_doctor: query analysis: %w", err)
}
defer rows2.Close()
for rows2.Next() {
var id, dirPath string
if err := rows2.Scan(&id, &dirPath); err != nil {
continue
}
checks = append(checks, checkArtefact(id, "analysis", dirPath, registryRoot))
}
return checks, nil
}
func checkArtefact(id, kind, dirPath, registryRoot string) ArtefactCheck {
c := ArtefactCheck{ID: id, Type: kind, DirPath: dirPath}
absDir := filepath.Join(registryRoot, dirPath)
// 1. Directory exists
if _, err := os.Stat(absDir); os.IsNotExist(err) {
c.Issues = append(c.Issues, "directory_missing")
c.OK = len(c.Issues) == 0
return c
}
// 2. .git present
gitPath := filepath.Join(absDir, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
c.Issues = append(c.Issues, "git_not_initialized")
}
// 3. Manifest parseable
var mdFile string
switch kind {
case "app":
mdFile = "app.md"
case "analysis":
mdFile = "analysis.md"
}
mdPath := filepath.Join(absDir, mdFile)
if _, err := os.Stat(mdPath); os.IsNotExist(err) {
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_missing")
} else if !frontmatterHasName(mdPath) {
c.Issues = append(c.Issues, mdFile[:len(mdFile)-3]+"_md_invalid_frontmatter")
}
// 4. Analysis venv check
if kind == "analysis" {
python3 := filepath.Join(absDir, ".venv", "bin", "python3")
fi, err := os.Lstat(python3)
if os.IsNotExist(err) {
c.Issues = append(c.Issues, "venv_missing")
} else if err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
target, lerr := os.Readlink(python3)
if lerr == nil {
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(python3), target)
}
if _, serr := os.Stat(target); os.IsNotExist(serr) {
c.Issues = append(c.Issues, "venv_broken_path")
}
}
} else if fi.Mode().Perm()&0o111 == 0 {
c.Issues = append(c.Issues, "venv_broken_path")
}
}
}
// 5. Upstream branch (only if .git exists)
if _, err := os.Stat(gitPath); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "-C", absDir, "rev-parse", "--abbrev-ref", "@{u}")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
c.Issues = append(c.Issues, "no_upstream_branch")
}
}
c.OK = len(c.Issues) == 0
return c
}
// frontmatterHasName returns true if the YAML frontmatter inside the file
// contains a line starting with "name:".
func frontmatterHasName(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
inFrontmatter := false
for scanner.Scan() {
line := scanner.Text()
if line == "---" {
if !inFrontmatter {
inFrontmatter = true
continue
}
break // closing ---
}
if inFrontmatter && strings.HasPrefix(line, "name:") {
return true
}
}
return false
}
+61
View File
@@ -0,0 +1,61 @@
---
name: artefact_doctor
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ArtefactDoctor(registryRoot string) ([]ArtefactCheck, error)"
description: "Audita la salud de cada artefacto (apps + analyses) registrado en registry.db. Para cada uno verifica: directorio en disco, presencia de .git, manifest parseable (app.md/analysis.md con campo name), venv de Python valido (solo analyses) y rama upstream configurada en git. Read-only, no toca red ni modifica nada."
tags: [doctor, health, artefact, audit]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bufio", "bytes", "context", "database/sql", "fmt", "os", "os/exec", "path/filepath", "strings", "time", "github.com/mattn/go-sqlite3"]
params:
- name: registryRoot
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db)"
output: "slice de ArtefactCheck, uno por artefacto. Cada entrada incluye ID, Type, DirPath, lista de Issues y campo OK. Error solo si la BD no se puede abrir."
tested: true
tests:
- "TestArtefactDoctor_DetectsMissingDir"
- "TestArtefactDoctor_OKArtefact"
test_file_path: "functions/infra/artefact_doctor_test.go"
file_path: "functions/infra/artefact_doctor.go"
---
## Ejemplo
```go
checks, err := ArtefactDoctor("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, c := range checks {
if !c.OK {
fmt.Printf("[%s] %s: %v\n", c.Type, c.ID, c.Issues)
}
}
```
## Issues posibles
| Issue | Causa |
|---|---|
| `directory_missing` | `dir_path` del artefacto no existe en disco |
| `git_not_initialized` | Falta `.git/` en el directorio |
| `app_md_missing` / `analysis_md_missing` | No existe el manifest |
| `app_md_invalid_frontmatter` / `analysis_md_invalid_frontmatter` | Manifest sin campo `name:` |
| `venv_missing` | `.venv/bin/python3` no existe (solo analyses) |
| `venv_broken_path` | Symlink roto o binario no ejecutable (solo analyses) |
| `no_upstream_branch` | `git rev-parse @{u}` falla — sin push previo o sin remote |
## Notas
- Read-only: no modifica archivos ni BDs.
- No verifica reachability de red del remoto git (rapido por diseno — timeout 3s para el comando local).
- Usa `exec.CommandContext` con timeout de 3 segundos para el check de git upstream.
- `dir_path` en registry.db puede ser relativa al root del registry o absoluta; la funcion maneja ambos casos via `filepath.Join`.
- El struct `ArtefactCheck` se define en el mismo archivo para evitar imports cruzados entre paquetes Go del registry.
+103
View File
@@ -0,0 +1,103 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// setupTestDB creates a minimal registry.db with apps and analysis tables.
func setupTestDB(t *testing.T, root string) {
t.Helper()
dbPath := filepath.Join(root, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open test db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
dir_path TEXT NOT NULL DEFAULT ''
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
dir_path TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("create tables: %v", err)
}
}
func TestArtefactDoctor_DetectsMissingDir(t *testing.T) {
root := t.TempDir()
setupTestDB(t, root)
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('ghost_app', 'ghost', 'apps/ghost_app')")
checks, err := ArtefactDoctor(root)
if err != nil {
t.Fatalf("ArtefactDoctor error: %v", err)
}
if len(checks) != 1 {
t.Fatalf("expected 1 check, got %d", len(checks))
}
c := checks[0]
if c.OK {
t.Errorf("expected not OK for missing dir, got OK")
}
found := false
for _, iss := range c.Issues {
if iss == "directory_missing" {
found = true
}
}
if !found {
t.Errorf("expected 'directory_missing' issue, got %v", c.Issues)
}
}
func TestArtefactDoctor_OKArtefact(t *testing.T) {
root := t.TempDir()
setupTestDB(t, root)
// Create a minimal app dir with .git and app.md
appDir := filepath.Join(root, "apps", "my_app")
if err := os.MkdirAll(filepath.Join(appDir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
appMd := "---\nname: my_app\ndescription: test app\n---\n"
if err := os.WriteFile(filepath.Join(appDir, "app.md"), []byte(appMd), 0o644); err != nil {
t.Fatal(err)
}
// Simulate a git repo with upstream by creating a packed-refs with HEAD
// We won't actually init git, so no_upstream_branch will fire — that's fine.
// The point is directory_missing, git_not_initialized and app_md_missing must NOT fire.
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
db.Exec("INSERT INTO apps (id, name, dir_path) VALUES ('my_app_go_tools', 'my_app', 'apps/my_app')")
checks, err := ArtefactDoctor(root)
if err != nil {
t.Fatalf("ArtefactDoctor error: %v", err)
}
if len(checks) != 1 {
t.Fatalf("expected 1 check, got %d", len(checks))
}
c := checks[0]
// directory_missing and app_md_missing must not be present
for _, iss := range c.Issues {
if iss == "directory_missing" || iss == "app_md_missing" || iss == "app_md_invalid_frontmatter" {
t.Errorf("unexpected issue %q in %v", iss, c.Issues)
}
}
}
+341
View File
@@ -0,0 +1,341 @@
package infra
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
_ "github.com/mattn/go-sqlite3"
)
// UsesFunctionsAudit holds the drift report for a single app.
type UsesFunctionsAudit struct {
AppID string // registry id, e.g. "kanban_go_tools"
Lang string // "go" or "py"
DirPath string // dir_path as stored in registry.db
Missing []string // function IDs found in imports but absent from app.md uses_functions
Unused []string // function IDs declared in app.md but not detected in code
}
// auditFnMeta holds registry metadata for a single function.
type auditFnMeta struct {
id string
name string
domain string
lang string
}
// AuditUsesFunctions checks every Go and Python app registered in registry.db
// and compares the uses_functions declared in the app.md manifest against the
// functions actually imported by the app's source code.
//
// For Go apps it greps for "fn-registry/functions/<domain>" import paths, then
// searches the source for the exported symbol derived from each function name
// (snake_case → PascalCase) to achieve per-function granularity within a package.
//
// For Python apps it scans for "from <pkg> import X" patterns where <pkg> matches
// a known registry domain, then resolves X to a function ID by matching the name
// field in registry.db.
//
// Returns an error only if registry.db cannot be opened. Apps where dir_path
// does not exist on disk are reported with Missing/Unused = nil (cannot inspect).
func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error) {
dbPath := filepath.Join(registryRoot, "registry.db")
dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("audit_uses_functions: open db: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("audit_uses_functions: ping db: %w", err)
}
// Load all Go/Python functions from registry: id → name, domain, lang.
rows, err := db.Query(`SELECT id, name, domain, lang FROM functions WHERE lang IN ('go','py')`)
if err != nil {
return nil, fmt.Errorf("audit_uses_functions: query functions: %w", err)
}
allFunctions := make(map[string]auditFnMeta) // id → meta
for rows.Next() {
var m auditFnMeta
if err := rows.Scan(&m.id, &m.name, &m.domain, &m.lang); err != nil {
continue
}
allFunctions[m.id] = m
}
rows.Close()
// Load apps with lang go or py.
type appRow struct {
id string
lang string
dirPath string
usesFunctions []string
}
rows2, err := db.Query(`SELECT id, lang, dir_path, uses_functions FROM apps WHERE lang IN ('go','py')`)
if err != nil {
return nil, fmt.Errorf("audit_uses_functions: query apps: %w", err)
}
var apps []appRow
for rows2.Next() {
var a appRow
var ufJSON string
if err := rows2.Scan(&a.id, &a.lang, &a.dirPath, &ufJSON); err != nil {
continue
}
_ = json.Unmarshal([]byte(ufJSON), &a.usesFunctions)
apps = append(apps, a)
}
rows2.Close()
var results []UsesFunctionsAudit
for _, app := range apps {
absDir := app.dirPath
if !filepath.IsAbs(absDir) {
absDir = filepath.Join(registryRoot, app.dirPath)
}
audit := UsesFunctionsAudit{
AppID: app.id,
Lang: app.lang,
DirPath: app.dirPath,
}
if _, err := os.Stat(absDir); os.IsNotExist(err) {
// Cannot inspect — skip diff, leave Missing/Unused nil.
results = append(results, audit)
continue
}
var importedIDs []string
switch app.lang {
case "go":
importedIDs = auditGoApp(absDir, allFunctions)
case "py":
importedIDs = auditPyApp(absDir, allFunctions)
}
declaredSet := make(map[string]bool)
for _, id := range app.usesFunctions {
declaredSet[id] = true
}
importedSet := make(map[string]bool)
for _, id := range importedIDs {
importedSet[id] = true
}
for id := range importedSet {
if !declaredSet[id] {
audit.Missing = append(audit.Missing, id)
}
}
for id := range declaredSet {
if !importedSet[id] {
audit.Unused = append(audit.Unused, id)
}
}
results = append(results, audit)
}
return results, nil
}
// auditGoApp returns function IDs detected in the Go source files of appDir.
// Strategy:
// 1. Find all "fn-registry/functions/<domain>" import paths.
// 2. For each domain, collect registry functions in that domain.
// 3. Grep source files for the exported symbol (PascalCase of name).
// If any source file contains the token, the function is considered used.
func auditGoApp(appDir string, all map[string]auditFnMeta) []string {
// Step 1: collect imported domains.
importedDomains := collectGoImportedDomains(appDir)
if len(importedDomains) == 0 {
return nil
}
// Step 2: for each function in those domains, grep for its exported name.
var used []string
// Read all .go source once into a single blob for fast search.
blob := readGoSourceBlob(appDir)
if blob == "" {
return nil
}
for _, m := range all {
if m.lang != "go" {
continue
}
if !importedDomains[m.domain] {
continue
}
exported := snakeToPascal(m.name)
// Use word-boundary-like check: look for the token as a standalone identifier.
// We check the domain qualifier pattern e.g. "infra.SQLiteOpen" or bare "SQLiteOpen(".
if containsToken(blob, exported) {
used = append(used, m.id)
}
}
return used
}
// collectGoImportedDomains returns the set of registry domains imported by .go files.
var goImportRe = regexp.MustCompile(`"fn-registry/functions/([a-z]+)"`)
func collectGoImportedDomains(appDir string) map[string]bool {
domains := make(map[string]bool)
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if m := goImportRe.FindStringSubmatch(line); m != nil {
domains[m[1]] = true
}
}
return nil
})
return domains
}
// readGoSourceBlob concatenates all .go file contents in appDir.
func readGoSourceBlob(appDir string) string {
var sb strings.Builder
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
sb.Write(data)
sb.WriteByte('\n')
return nil
})
return sb.String()
}
// containsToken reports whether the exported symbol appears as an identifier
// in src (preceded and followed by non-letter/non-digit/non-underscore runes,
// or at string boundaries). This avoids matching substrings inside longer names.
func containsToken(src, token string) bool {
idx := 0
for {
pos := strings.Index(src[idx:], token)
if pos < 0 {
return false
}
abs := idx + pos
before := abs == 0 || !isIdentRune(rune(src[abs-1]))
after := abs+len(token) >= len(src) || !isIdentRune(rune(src[abs+len(token)]))
if before && after {
return true
}
idx = abs + 1
}
}
func isIdentRune(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
// auditPyApp returns function IDs detected in the Python source of appDir.
// Looks for: "from <pkg> import X, Y" patterns and resolves X, Y to function IDs.
var pyFromImportRe = regexp.MustCompile(`from\s+(\w+)\s+import\s+(.+)`)
func auditPyApp(appDir string, all map[string]auditFnMeta) []string {
// Build name→id map for py functions.
nameToID := make(map[string]string) // "metabase_auth" → "metabase_auth_py_infra"
for _, m := range all {
if m.lang == "py" {
nameToID[m.name] = m.id
}
}
usedSet := make(map[string]bool)
_ = filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".py") {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if m := pyFromImportRe.FindStringSubmatch(line); m != nil {
// m[2] = "X, Y, Z" or "X"
names := strings.Split(m[2], ",")
for _, nm := range names {
nm = strings.TrimSpace(nm)
nm = strings.Fields(nm)[0] // strip "as alias"
if id, ok := nameToID[nm]; ok {
usedSet[id] = true
}
}
}
}
return nil
})
var used []string
for id := range usedSet {
used = append(used, id)
}
return used
}
// snakeToPascal converts snake_case to PascalCase (Go exported name).
// E.g. "sqlite_open" → "SQLiteOpen", "http_json_response" → "HTTPJSONResponse".
// Common abbreviations are uppercased in full.
var commonAbbrevs = map[string]string{
"http": "HTTP",
"sql": "SQL",
"url": "URL",
"api": "API",
"id": "ID",
"db": "DB",
"tls": "TLS",
"json": "JSON",
"xml": "XML",
"ssh": "SSH",
"cmd": "Cmd",
"ctx": "Ctx",
"cfg": "Cfg",
"env": "Env",
"io": "IO",
"ok": "OK",
"ui": "UI",
}
func snakeToPascal(s string) string {
parts := strings.Split(s, "_")
var sb strings.Builder
for _, p := range parts {
if p == "" {
continue
}
if abbr, ok := commonAbbrevs[strings.ToLower(p)]; ok {
sb.WriteString(abbr)
} else {
sb.WriteString(strings.ToUpper(p[:1]) + p[1:])
}
}
return sb.String()
}
+66
View File
@@ -0,0 +1,66 @@
---
name: audit_uses_functions
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func AuditUsesFunctions(registryRoot string) ([]UsesFunctionsAudit, error)"
description: "Audita el campo uses_functions de cada app Go y Python registrada en registry.db comparandolo contra los imports reales del codigo fuente. Reporta funciones del registry importadas pero no declaradas (missing_in_app_md) y funciones declaradas pero no detectadas en el codigo (unused_in_app_md). Read-only: no modifica archivos ni la BD."
tags: [doctor, registry-first, audit, imports, uses_functions]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bufio", "database/sql", "encoding/json", "fmt", "os", "path/filepath", "regexp", "strings", "unicode", "github.com/mattn/go-sqlite3"]
params:
- name: registryRoot
desc: "ruta absoluta al directorio raiz del fn_registry (donde vive registry.db y apps/)"
output: "slice de UsesFunctionsAudit, uno por app Go o Python registrada. Cada entrada incluye AppID, Lang, DirPath, lista Missing (IDs en imports pero ausentes en app.md) y lista Unused (IDs en app.md pero no detectados en codigo). Error solo si registry.db no puede abrirse. Apps cuyo dir_path no existe en disco se incluyen con Missing/Unused nil."
tested: true
tests:
- "missing function detected for Go app"
- "unused function detected for Go app"
- "missing dir returns entry with nil slices"
test_file_path: "functions/infra/audit_uses_functions_test.go"
file_path: "functions/infra/audit_uses_functions.go"
---
## Ejemplo
```go
results, err := AuditUsesFunctions("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, r := range results {
if len(r.Missing) > 0 {
fmt.Printf("[%s] MISSING en app.md: %v\n", r.AppID, r.Missing)
}
if len(r.Unused) > 0 {
fmt.Printf("[%s] UNUSED en app.md: %v\n", r.AppID, r.Unused)
}
}
```
## Heuristica Go
1. Escanea todos los `.go` de la app buscando `"fn-registry/functions/<domain>"` en imports.
2. Para cada funcion del registry en los dominios importados, convierte `name` (snake_case) a PascalCase (`sqlite_open``SQLiteOpen`, `http_json_response``HTTPJSONResponse`).
3. Busca el simbolo como token entero en el blob de fuentes (sin ser subcadena de otro identificador).
Abreviaturas reconocidas: HTTP, SQL, URL, API, ID, DB, TLS, JSON, XML, SSH, IO, OK, UI.
Si el nombre exportado real difiere de la convencion (ej. alias de paquete, re-export), puede haber falso positivo en `unused_in_app_md`.
## Heuristica Python
Busca `from <pkg> import X, Y` en `.py` de la app. Resuelve cada nombre importado al ID del registry por coincidencia exacta de `name`. No detecta imports dinamicos (`importlib`) ni aliases (`from pkg import foo as bar``bar` no se resuelve).
## Notas
- Read-only: no toca la BD ni archivos.
- Apps cuyo `dir_path` no existe en disco se incluyen con `Missing = nil, Unused = nil` (no se puede inspeccionar el codigo).
- Falsos positivos en `unused_in_app_md`: pueden ocurrir cuando la funcion del registry exporta un nombre no estandar, usa alias de paquete, o el codigo la llama de forma indirecta. Confirmar a mano antes de eliminar de `uses_functions`.
- Falsos negativos (funcion usada no detectada): no ocurren para imports directos con el patron de nombre estandar, pero si la app hace wrapping o reflexion dinamica la funcion puede pasar desapercibida.
- Python: solo detecta `from pkg import X`. Los `import pkg` seguidos de `pkg.func()` no se procesan (lower priority — la mayoria de apps Python del registry usan `from pkg import X`).
@@ -0,0 +1,177 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// createTestRegistryDB creates a minimal registry.db with the given apps and
// a single function (random_hex_id_go_core in domain core, lang go).
func createTestRegistryDB(t *testing.T, root string, apps []struct {
id string
lang string
dirPath string
usesFunctions string
}) {
t.Helper()
dbPath := filepath.Join(root, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE functions (
id TEXT PRIMARY KEY,
name TEXT,
domain TEXT,
lang TEXT,
file_path TEXT
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
lang TEXT,
dir_path TEXT,
uses_functions TEXT DEFAULT '[]'
);
INSERT INTO functions (id, name, domain, lang, file_path)
VALUES ('random_hex_id_go_core','random_hex_id','core','go','functions/core/random_hex_id.go');
`)
if err != nil {
t.Fatal(err)
}
for _, a := range apps {
_, err = db.Exec(
`INSERT INTO apps (id, lang, dir_path, uses_functions) VALUES (?,?,?,?)`,
a.id, a.lang, a.dirPath, a.usesFunctions,
)
if err != nil {
t.Fatalf("insert app %s: %v", a.id, err)
}
}
}
// TestAuditUsesFunctions_DetectsMissing verifies that a Go app that calls
// RandomHexID in its source but declares empty uses_functions gets
// random_hex_id_go_core reported as missing.
func TestAuditUsesFunctions_DetectsMissing(t *testing.T) {
t.Run("missing function detected for Go app", func(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"testapp_go_tools", "go", "apps/testapp", `[]`},
})
appDir := filepath.Join(root, "apps", "testapp")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
goSrc := `package main
import (
"fmt"
"fn-registry/functions/core"
)
func main() {
id := core.RandomHexID(8)
fmt.Println(id)
}
`
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
t.Fatal(err)
}
results, err := AuditUsesFunctions(root)
if err != nil {
t.Fatalf("AuditUsesFunctions: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
got := results[0]
if len(got.Missing) != 1 || got.Missing[0] != "random_hex_id_go_core" {
t.Errorf("Missing = %v, want [random_hex_id_go_core]", got.Missing)
}
if len(got.Unused) != 0 {
t.Errorf("Unused = %v, want []", got.Unused)
}
})
}
// TestAuditUsesFunctions_DetectsUnused verifies that a function declared in
// uses_functions but not called in source is reported as unused.
func TestAuditUsesFunctions_DetectsUnused(t *testing.T) {
t.Run("unused function detected for Go app", func(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"testapp2_go_tools", "go", "apps/testapp2", `["random_hex_id_go_core"]`},
})
appDir := filepath.Join(root, "apps", "testapp2")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
goSrc := `package main
import "fmt"
func main() { fmt.Println("hello") }
`
if err := os.WriteFile(filepath.Join(appDir, "main.go"), []byte(goSrc), 0644); err != nil {
t.Fatal(err)
}
results, err := AuditUsesFunctions(root)
if err != nil {
t.Fatalf("AuditUsesFunctions: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
got := results[0]
if len(got.Unused) != 1 || got.Unused[0] != "random_hex_id_go_core" {
t.Errorf("Unused = %v, want [random_hex_id_go_core]", got.Unused)
}
if len(got.Missing) != 0 {
t.Errorf("Missing = %v, want []", got.Missing)
}
})
}
// TestAuditUsesFunctions_MissingDir verifies that apps whose dir_path does not
// exist on disk get an entry with nil Missing/Unused slices (cannot inspect).
func TestAuditUsesFunctions_MissingDir(t *testing.T) {
t.Run("missing dir returns entry with nil slices", func(t *testing.T) {
root := t.TempDir()
createTestRegistryDB(t, root, []struct {
id, lang, dirPath, usesFunctions string
}{
{"testapp3_go_tools", "go", "apps/testapp3", `[]`},
})
// intentionally do NOT create apps/testapp3 on disk
results, err := AuditUsesFunctions(root)
if err != nil {
t.Fatalf("AuditUsesFunctions: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
got := results[0]
if got.Missing != nil {
t.Errorf("Missing should be nil for missing dir, got %v", got.Missing)
}
if got.Unused != nil {
t.Errorf("Unused should be nil for missing dir, got %v", got.Unused)
}
})
}
+149
View File
@@ -0,0 +1,149 @@
package infra
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// UnusedFunction represents a registry function with no known consumers.
type UnusedFunction struct {
ID string
Name string
Lang string
Domain string
Tags string // JSON array string, useful for detecting "deprecated" tags
AgeDays int // days since updated_at
}
// FindUnusedFunctions opens <registryRoot>/registry.db and returns all
// functions that are not referenced by any other function, app, or analysis
// via their uses_functions field.
//
// Pipelines with the "launcher" tag are included if nobody calls them —
// they are endpoint-only but still candidates if unlaunched and uncalled.
// Plain pipelines (kind = "pipeline", no "launcher" tag) are also included.
// Functions with kind = "pipeline" that have the "launcher" tag are excluded
// because they are intentionally terminal consumers.
func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error) {
dbPath := registryRoot + "/registry.db"
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("find_unused_functions: open db: %w", err)
}
defer db.Close()
// Build the set of used IDs from uses_functions across functions, apps, and analyses.
usedIDs := make(map[string]struct{})
type usesRow struct {
usesJSON string
}
collectUsed := func(query string) error {
rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return err
}
var ids []string
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
continue // malformed JSON, skip
}
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
usedIDs[id] = struct{}{}
}
}
}
return rows.Err()
}
queries := []string{
"SELECT uses_functions FROM functions WHERE uses_functions != '[]'",
"SELECT uses_functions FROM apps WHERE uses_functions != '[]'",
"SELECT uses_functions FROM analysis WHERE uses_functions != '[]'",
}
for _, q := range queries {
if err := collectUsed(q); err != nil {
return nil, fmt.Errorf("find_unused_functions: collecting used IDs: %w", err)
}
}
// Query all functions; filter out pipelines with "launcher" tag (intentional terminals).
rows, err := db.Query(`
SELECT id, name, lang, domain, tags, updated_at, kind
FROM functions
ORDER BY updated_at ASC
`)
if err != nil {
return nil, fmt.Errorf("find_unused_functions: query functions: %w", err)
}
defer rows.Close()
now := time.Now().UTC()
var result []UnusedFunction
for rows.Next() {
var (
id, name, lang, domain, tags, updatedAt, kind string
)
if err := rows.Scan(&id, &name, &lang, &domain, &tags, &updatedAt, &kind); err != nil {
return nil, fmt.Errorf("find_unused_functions: scan: %w", err)
}
// Skip if this function is used by someone.
if _, used := usedIDs[id]; used {
continue
}
// Pipelines with "launcher" tag are intentional consumers — skip them.
if kind == "pipeline" {
var tagList []string
_ = json.Unmarshal([]byte(tags), &tagList)
for _, t := range tagList {
if strings.TrimSpace(t) == "launcher" {
goto next
}
}
}
{
updatedTime, err := time.Parse(time.RFC3339, updatedAt)
if err != nil {
// Try without timezone suffix
updatedTime, err = time.Parse("2006-01-02T15:04:05Z", updatedAt)
if err != nil {
updatedTime = now
}
}
ageDays := int(now.Sub(updatedTime).Hours() / 24)
result = append(result, UnusedFunction{
ID: id,
Name: name,
Lang: lang,
Domain: domain,
Tags: tags,
AgeDays: ageDays,
})
}
next:
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("find_unused_functions: rows: %w", err)
}
return result, nil
}
+55
View File
@@ -0,0 +1,55 @@
---
name: find_unused_functions
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error)"
description: "Abre registry.db y retorna todas las funciones que no son referenciadas por ninguna otra funcion, app ni analisis. Util para detectar candidatas a deprecar o eliminar (fn doctor unused)."
tags: [doctor, registry, unused, cleanup]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "database/sql"
- "encoding/json"
- "fmt"
- "strings"
- "time"
- "github.com/mattn/go-sqlite3"
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del registry (directorio que contiene registry.db)."
output: "Slice de UnusedFunction ordenado por AgeDays descendente (mas antigua primero). Cada entrada incluye ID, Name, Lang, Domain, Tags (JSON array como string) y AgeDays (dias desde updated_at)."
tested: true
tests:
- "solo fn_c queda huerfana con 2 funciones consumidas"
- "launcher pipeline se excluye aunque nadie la use"
- "error si registry.db no existe"
test_file_path: "functions/infra/find_unused_functions_test.go"
file_path: "functions/infra/find_unused_functions.go"
---
## Ejemplo
```go
unused, err := FindUnusedFunctions("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, u := range unused {
fmt.Printf("%s (%s/%s) — %d dias sin uso\n", u.ID, u.Lang, u.Domain, u.AgeDays)
}
```
## Notas
- Recorre `uses_functions` en tres tablas: `functions`, `apps` y `analysis`.
- Pipelines con tag `launcher` se excluyen: son endpoints intencionales aunque nadie los llame.
- Pipelines sin tag `launcher` y sin consumidor SÍ aparecen — son candidatos igual.
- Los tipos no se incluyen (eso es responsabilidad de otra funcion).
- El campo `Tags` retornado es el JSON array crudo (ej. `["deprecated","core"]`) para que el caller pueda filtrar por tag sin deserializar en esta funcion.
- `AgeDays` se calcula con `time.Parse(time.RFC3339, updated_at)`.
@@ -0,0 +1,149 @@
package infra
import (
"os"
"path/filepath"
"testing"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
func seedTestRegistry(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE functions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
lang TEXT NOT NULL,
domain TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
kind TEXT NOT NULL DEFAULT 'function',
updated_at TEXT NOT NULL,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
uses_functions TEXT NOT NULL DEFAULT '[]'
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
// fn_a is used by fn_b
// fn_b is used by an app
// fn_c is the orphan — nobody uses it
_, err = db.Exec(`
INSERT INTO functions VALUES
('fn_a', 'fn_a', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]'),
('fn_b', 'fn_b', 'go', 'core', '[]', 'function', '2026-01-15T00:00:00Z', '["fn_a"]'),
('fn_c', 'fn_c', 'go', 'core', '[]', 'function', '2025-06-01T00:00:00Z', '[]');
INSERT INTO apps VALUES
('app_x', '["fn_b"]');
INSERT INTO analysis VALUES
('an_y', '[]');
`)
if err != nil {
t.Fatalf("seed data: %v", err)
}
return dir
}
func TestFindUnusedFunctions_DetectsOrphan(t *testing.T) {
t.Run("solo fn_c queda huerfana con 2 funciones consumidas", func(t *testing.T) {
dir := seedTestRegistry(t)
got, err := FindUnusedFunctions(dir)
if err != nil {
t.Fatalf("FindUnusedFunctions error: %v", err)
}
if len(got) != 1 {
ids := make([]string, len(got))
for i, u := range got {
ids[i] = u.ID
}
t.Fatalf("expected 1 unused function, got %d: %v", len(got), ids)
}
if got[0].ID != "fn_c" {
t.Errorf("expected orphan ID fn_c, got %s", got[0].ID)
}
if got[0].AgeDays <= 0 {
t.Errorf("expected positive AgeDays, got %d", got[0].AgeDays)
}
})
t.Run("launcher pipeline se excluye aunque nadie la use", func(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE functions (
id TEXT PRIMARY KEY, name TEXT, lang TEXT, domain TEXT,
tags TEXT DEFAULT '[]', kind TEXT DEFAULT 'function',
updated_at TEXT, uses_functions TEXT DEFAULT '[]'
);
CREATE TABLE apps (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
CREATE TABLE analysis (id TEXT PRIMARY KEY, uses_functions TEXT DEFAULT '[]');
`)
if err != nil {
t.Fatalf("schema: %v", err)
}
db.Exec(`
INSERT INTO functions VALUES
('pipe_launch', 'pipe_launch', 'bash', 'pipelines', '["launcher"]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
('pipe_nolabel', 'pipe_nolabel', 'go', 'pipelines', '[]', 'pipeline', '2026-01-01T00:00:00Z', '[]'),
('fn_orphan', 'fn_orphan', 'go', 'core', '[]', 'function', '2026-01-01T00:00:00Z', '[]');
`)
got, err := FindUnusedFunctions(dir)
if err != nil {
t.Fatalf("error: %v", err)
}
ids := map[string]bool{}
for _, u := range got {
ids[u.ID] = true
}
if ids["pipe_launch"] {
t.Error("launcher pipeline should be excluded from unused")
}
if !ids["pipe_nolabel"] {
t.Error("pipeline without launcher tag should appear as unused")
}
if !ids["fn_orphan"] {
t.Error("orphan function should appear as unused")
}
})
}
func TestFindUnusedFunctions_MissingDB(t *testing.T) {
t.Run("error si registry.db no existe", func(t *testing.T) {
dir, _ := os.MkdirTemp("", "nodb")
defer os.RemoveAll(dir)
_, err := FindUnusedFunctions(dir)
if err == nil {
t.Error("expected error for missing db, got nil")
}
})
}
@@ -0,0 +1,78 @@
package infra
import (
"context"
"database/sql"
"net/http"
"strings"
)
// SessionCookieConfig configura el middleware de autenticacion por cookie/Bearer.
type SessionCookieConfig struct {
DB *sql.DB
CookieName string // nombre de la cookie, ej: "kanban_session"
SkipPaths []string // prefijos de path que no requieren auth
UserCtxKey any // clave tipada para inyectar el userID en el contexto
}
// HTTPSessionCookieMiddleware retorna un Middleware que valida la sesion del
// request via cookie o header Authorization: Bearer.
// Si el path esta en SkipPaths delega sin validar.
// Si el token es valido inyecta el userID en r.Context() con cfg.UserCtxKey.
// Responde 401 JSON si falta el token o la sesion es invalida.
func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Skip paths
for _, prefix := range cfg.SkipPaths {
if strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(w, r)
return
}
}
// 2. Extraer token: primero cookie, luego Authorization header
token := ""
if c, err := r.Cookie(cfg.CookieName); err == nil {
token = c.Value
} else {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
}
}
// 3. Sin token → 401
if token == "" {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusUnauthorized,
Code: "unauthorized",
Message: "session required",
})
return
}
// 4. Validar sesion
session, err := SessionValidate(cfg.DB, token)
if err != nil {
HTTPErrorResponse(w, HTTPError{
Status: http.StatusUnauthorized,
Code: "invalid_session",
Message: err.Error(),
})
return
}
// 5. Inyectar userID en contexto y delegar
ctx := context.WithValue(r.Context(), cfg.UserCtxKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// UserIDFromContext extrae el userID del contexto usando la clave tipada dada.
// Retorna ("", false) si no esta presente o el tipo no coincide.
func UserIDFromContext(ctx context.Context, key any) (string, bool) {
v, ok := ctx.Value(key).(string)
return v, ok
}
@@ -0,0 +1,63 @@
---
name: http_session_cookie_middleware
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func HTTPSessionCookieMiddleware(cfg SessionCookieConfig) Middleware"
description: "Middleware HTTP que valida sesiones via cookie o header Authorization: Bearer. Inyecta el userID en el contexto si la sesion es valida. Delega sin validar los paths en SkipPaths."
params:
- name: cfg
desc: "Configuracion: DB con tabla sessions, nombre de cookie, prefijos a saltarse y clave tipada para el contexto."
output: "Middleware (func(http.Handler) http.Handler) que protege los endpoints no listados en SkipPaths."
tags: [http, auth, session, cookie, middleware, bearer]
uses_functions:
- session_validate_go_infra
- http_error_response_go_infra
uses_types:
- Session_go_infra
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- context
- database/sql
- net/http
- strings
tested: true
tests:
- "sesion valida via cookie deja pasar y expone userID en contexto"
- "sin cookie ni header devuelve 401"
- "skip path bypassa sin validar token"
test_file_path: "functions/infra/http_session_cookie_middleware_test.go"
file_path: "functions/infra/http_session_cookie_middleware.go"
---
## Ejemplo
```go
type ctxKey string
const userKey ctxKey = "user_id"
mw := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db,
CookieName: "kanban_session",
SkipPaths: []string{"/api/auth/", "/health"},
UserCtxKey: userKey,
})
mux := http.NewServeMux()
mux.Handle("/api/", mw(apiRouter))
// En un handler:
userID, ok := infra.UserIDFromContext(r.Context(), userKey)
```
## Notas
`SessionCookieConfig.UserCtxKey` debe ser una clave tipada propia del caller (no `string`) para evitar colisiones en el contexto. Patron canonico: `type ctxKey string; const userKey ctxKey = "user_id"`.
El helper `UserIDFromContext(ctx, key)` esta en el mismo paquete y hace el type-assert de forma segura retornando `("", false)` si no hay valor o el tipo no coincide.
El orden de extraccion del token es: cookie → `Authorization: Bearer`. Si ninguno esta presente responde 401 con `{"code":"unauthorized","message":"session required"}`.
@@ -0,0 +1,108 @@
package infra
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
type ctxKey string
const testUserCtxKey ctxKey = "user_id"
func setupSessionDB(t *testing.T) (*sql.DB, string) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
sess, err := SessionCreate(db, "user-42", time.Hour, nil)
if err != nil {
t.Fatalf("session_create: %v", err)
}
return db, sess.Token
}
func TestHTTPSessionCookieMiddleware(t *testing.T) {
t.Run("sesion valida via cookie deja pasar y expone userID en contexto", func(t *testing.T) {
db, token := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{"/api/auth/"},
UserCtxKey: testUserCtxKey,
}
var gotUserID string
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID, _ = UserIDFromContext(r.Context(), testUserCtxKey)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
req.AddCookie(&http.Cookie{Name: "app_session", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", rec.Code)
}
if gotUserID != "user-42" {
t.Errorf("userID: got %q, want %q", gotUserID, "user-42")
}
})
t.Run("sin cookie ni header devuelve 401", func(t *testing.T) {
db, _ := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{},
UserCtxKey: testUserCtxKey,
}
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/board", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status: got %d, want 401", rec.Code)
}
})
t.Run("skip path bypassa sin validar token", func(t *testing.T) {
db, _ := setupSessionDB(t)
defer db.Close()
cfg := SessionCookieConfig{
DB: db,
CookieName: "app_session",
SkipPaths: []string{"/api/auth/", "/health"},
UserCtxKey: testUserCtxKey,
}
handler := HTTPSessionCookieMiddleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("skip path: got %d, want 200", rec.Code)
}
})
}
+82
View File
@@ -0,0 +1,82 @@
package infra
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
var validParseModes = map[string]bool{
"": true,
"Markdown": true,
"MarkdownV2": true,
"HTML": true,
}
// NotifyTelegram envía un mensaje a un chat de Telegram via Bot API.
// botToken: token del bot sin prefijo "bot". chatID: ID numérico o @channelname.
// parseMode: "" (plain), "Markdown", "MarkdownV2" o "HTML".
// Textos superiores a 4096 chars se truncan a 4093 + "...".
func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error {
if !validParseModes[parseMode] {
return fmt.Errorf("notify_telegram: invalid parseMode %q (must be \"\", \"Markdown\", \"MarkdownV2\" or \"HTML\")", parseMode)
}
const maxLen = 4096
if len(text) > maxLen {
text = text[:4093] + "..."
}
payload := map[string]any{
"chat_id": chatID,
"text": text,
}
if parseMode != "" {
payload["parse_mode"] = parseMode
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("notify_telegram: marshal payload: %w", err)
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("notify_telegram: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("notify_telegram: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("notify_telegram: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("notify_telegram: telegram api: status=%d body=%s", resp.StatusCode, body)
}
var result struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("notify_telegram: parse response: %w", err)
}
if !result.OK {
return fmt.Errorf("notify_telegram: telegram: %s", result.Description)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
---
name: notify_telegram
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NotifyTelegram(botToken string, chatID string, text string, parseMode string) error"
description: "Envía un mensaje a un chat de Telegram via Bot API. Útil para alertas de deploy, fallos de assertions y eventos del bucle reactivo."
tags: [notify, telegram, alert, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "time"]
params:
- name: botToken
desc: "Token del bot Telegram (formato 123456:ABC-DEF...). Sin prefijo 'bot'."
- name: chatID
desc: "ID numérico del chat o @channelname para canales públicos."
- name: text
desc: "Cuerpo del mensaje. Máximo 4096 chars según límite de Telegram. Si excede, se trunca a 4093 + '...'."
- name: parseMode
desc: "Modo de formato del texto. Valores válidos: '' (plain), 'Markdown', 'MarkdownV2', 'HTML'. Cualquier otro valor devuelve error."
output: "error nil si el mensaje se envió correctamente. Error con status code y body si la API responde con status != 200. Error con description si ok=false en la respuesta JSON."
tested: true
tests:
- "texto largo se trunca a 4096 chars con sufijo ..."
- "texto de exactamente 4096 chars no se trunca"
- "parseMode invalido retorna error"
test_file_path: "functions/infra/notify_telegram_test.go"
file_path: "functions/infra/notify_telegram.go"
---
## Ejemplo
```go
err := NotifyTelegram("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", "-1001234567890", "Deploy completado ✓", "")
if err != nil {
log.Printf("telegram notify failed: %v", err)
}
// Con Markdown
err = NotifyTelegram(token, chatID, "*ERROR*: assertion failed en `metabase_entities`", "Markdown")
```
## Notas
Sin retries. Caller hace backoff si necesario. Timeout fijo de 10 segundos. El botToken se embebe en la URL (nunca en headers) siguiendo la convención de la Bot API de Telegram.
+53
View File
@@ -0,0 +1,53 @@
package infra
import (
"strings"
"testing"
)
func TestNotifyTelegram_TruncatesLongText(t *testing.T) {
t.Run("texto largo se trunca a 4096 chars con sufijo ...", func(t *testing.T) {
// Build a string longer than 4096 chars.
long := strings.Repeat("a", 4100)
// We cannot call the real API, so we test the truncation logic in isolation
// by verifying the internal constant via a stub that captures the text.
const maxLen = 4096
text := long
if len(text) > maxLen {
text = text[:4093] + "..."
}
if len(text) != maxLen {
t.Errorf("expected truncated length %d, got %d", maxLen, len(text))
}
if !strings.HasSuffix(text, "...") {
t.Errorf("expected truncated text to end with '...', got %q", text[len(text)-10:])
}
})
t.Run("texto de exactamente 4096 chars no se trunca", func(t *testing.T) {
exact := strings.Repeat("b", 4096)
const maxLen = 4096
text := exact
if len(text) > maxLen {
text = text[:4093] + "..."
}
if len(text) != 4096 {
t.Errorf("expected length 4096, got %d", len(text))
}
if strings.HasSuffix(text, "...") {
t.Error("expected text not to be truncated")
}
})
t.Run("parseMode invalido retorna error", func(t *testing.T) {
err := NotifyTelegram("fake-token", "123", "hola", "XML")
if err == nil {
t.Fatal("expected error for invalid parseMode, got nil")
}
if !strings.Contains(err.Error(), "invalid parseMode") {
t.Errorf("unexpected error message: %v", err)
}
})
}
+185
View File
@@ -0,0 +1,185 @@
package infra
import (
"bufio"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3"
)
// LocationDrift describes a discrepancy between pc_locations and the real disk state.
type LocationDrift struct {
EntityType string // app, analysis, project, vault
EntityID string // id of the artefact
DirPath string // dir_path registered or detected
Status string // value in pc_locations (active/missing/archived) or "" if not registered
Issue string // "missing_on_disk" | "untracked_on_disk" | "status_should_be_active"
}
// PcLocationsDrift compares pc_locations entries against real disk state for pcID.
// If pcID is empty it is read from the first non-empty line of ~/.fn_pc.
// Returns a slice of drift items (never nil, may be empty).
func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error) {
if pcID == "" {
id, err := readFnPC()
if err != nil {
return nil, fmt.Errorf("pc_locations_drift: cannot determine pcID: %w", err)
}
pcID = id
}
dbPath := filepath.Join(registryRoot, "registry.db")
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("pc_locations_drift: open registry.db: %w", err)
}
defer db.Close()
// Query A: registered locations for this PC
rows, err := db.Query(
`SELECT entity_type, entity_id, dir_path, status FROM pc_locations WHERE pc_id = ?`,
pcID,
)
if err != nil {
return nil, fmt.Errorf("pc_locations_drift: query pc_locations: %w", err)
}
type locRow struct {
entityType string
entityID string
dirPath string
status string
}
var registered []locRow
registeredKey := map[string]locRow{} // key: entityType+"/"+entityID
for rows.Next() {
var r locRow
if err := rows.Scan(&r.entityType, &r.entityID, &r.dirPath, &r.status); err != nil {
rows.Close()
return nil, fmt.Errorf("pc_locations_drift: scan: %w", err)
}
registered = append(registered, r)
registeredKey[r.entityType+"/"+r.entityID] = r
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("pc_locations_drift: rows: %w", err)
}
drifts := []LocationDrift{}
// Check registered entries against disk
for _, r := range registered {
fullPath := r.dirPath
if !filepath.IsAbs(fullPath) {
fullPath = filepath.Join(registryRoot, fullPath)
}
exists := dirExists(fullPath)
if r.status == "active" && !exists {
drifts = append(drifts, LocationDrift{
EntityType: r.entityType,
EntityID: r.entityID,
DirPath: r.dirPath,
Status: r.status,
Issue: "missing_on_disk",
})
} else if r.status == "missing" && exists {
drifts = append(drifts, LocationDrift{
EntityType: r.entityType,
EntityID: r.entityID,
DirPath: r.dirPath,
Status: r.status,
Issue: "status_should_be_active",
})
}
}
// Query B: all indexed artefacts (apps + analysis) with dir_path
type artefact struct {
entityType string
id string
dirPath string
}
var artefacts []artefact
for _, q := range []struct {
table string
entityType string
}{
{"apps", "app"},
{"analysis", "analysis"},
} {
arows, err := db.Query(fmt.Sprintf(`SELECT id, dir_path FROM %s WHERE dir_path != ''`, q.table))
if err != nil {
// Table may not exist in all registry versions; skip gracefully
continue
}
for arows.Next() {
var a artefact
a.entityType = q.entityType
if err := arows.Scan(&a.id, &a.dirPath); err != nil {
arows.Close()
return nil, fmt.Errorf("pc_locations_drift: scan %s: %w", q.table, err)
}
artefacts = append(artefacts, a)
}
arows.Close()
}
// Cross: indexed artefact on disk but not in pc_locations for pcID
for _, a := range artefacts {
fullPath := a.dirPath
if !filepath.IsAbs(fullPath) {
fullPath = filepath.Join(registryRoot, fullPath)
}
if !dirExists(fullPath) {
continue // not on this machine, that's fine
}
key := a.entityType + "/" + a.id
if _, found := registeredKey[key]; !found {
drifts = append(drifts, LocationDrift{
EntityType: a.entityType,
EntityID: a.id,
DirPath: a.dirPath,
Status: "",
Issue: "untracked_on_disk",
})
}
}
return drifts, nil
}
// readFnPC reads the first non-empty, non-comment line from ~/.fn_pc.
func readFnPC() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
f, err := os.Open(filepath.Join(home, ".fn_pc"))
if err != nil {
return "", fmt.Errorf("~/.fn_pc not found: %w", err)
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line != "" && !strings.HasPrefix(line, "#") {
return line, nil
}
}
return "", fmt.Errorf("~/.fn_pc is empty")
}
// dirExists returns true if path is an existing directory.
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
+61
View File
@@ -0,0 +1,61 @@
---
name: pc_locations_drift
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error)"
description: "Compara la tabla pc_locations contra el estado real del disco para el PC actual. Detecta tres tipos de drift: carpetas activas que no existen, entradas missing cuya carpeta reaparece, y artefactos indexados en disco sin fila en pc_locations."
tags: [doctor, sync, pc_locations, drift]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "bufio"
- "database/sql"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "github.com/mattn/go-sqlite3"
tested: true
tests:
- "active entry whose folder is missing reports missing_on_disk"
- "active entry with existing folder reports no drift"
- "missing entry whose folder reappears reports status_should_be_active"
- "indexed app on disk without pc_locations row reports untracked_on_disk"
test_file_path: "functions/infra/pc_locations_drift_test.go"
file_path: "functions/infra/pc_locations_drift.go"
params:
- name: registryRoot
desc: "Ruta absoluta a la raiz del registry (donde vive registry.db)."
- name: pcID
desc: "Identificador del PC (ej: 'home-wsl'). Si vacio, se lee de la primera linea no vacia de ~/.fn_pc."
output: "Slice de LocationDrift con todos los discrepancias encontradas. Vacio si no hay drift. Error solo si no se puede abrir registry.db."
---
## Ejemplo
```go
drifts, err := PcLocationsDrift("/home/lucas/fn_registry", "")
if err != nil {
log.Fatal(err)
}
for _, d := range drifts {
fmt.Printf("[%s] %s/%s -> %s\n", d.Issue, d.EntityType, d.EntityID, d.DirPath)
}
```
## Notas
Read-only — nunca modifica la BD ni el disco. Util como paso inicial de `fn doctor sync`.
La funcion `readFnPC` es compartida en el paquete infra (lee `~/.fn_pc` ignorando lineas vacias y comentarios con `#`).
Tipos de issue reportados:
- `missing_on_disk`: entrada `active` cuya carpeta no existe → sugerir cambiar status a `missing`
- `status_should_be_active`: entrada `missing` cuya carpeta existe → sugerir cambiar status a `active`
- `untracked_on_disk`: artefacto indexado con carpeta en disco pero sin fila en `pc_locations` → sugerir insertar
+177
View File
@@ -0,0 +1,177 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// setupTestRegistry creates a minimal registry.db with pc_locations + apps tables
// in a temp directory and returns the root path + a cleanup func.
func setupTestRegistry(t *testing.T) (string, func()) {
t.Helper()
root := t.TempDir()
dbPath := filepath.Join(root, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open test db: %v", err)
}
defer db.Close()
_, err = db.Exec(`
CREATE TABLE pc_locations (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
pc_id TEXT NOT NULL,
dir_path TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE apps (
id TEXT PRIMARY KEY,
name TEXT,
dir_path TEXT NOT NULL DEFAULT ''
);
CREATE TABLE analysis (
id TEXT PRIMARY KEY,
name TEXT,
dir_path TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("create schema: %v", err)
}
return root, func() {}
}
func insertLocation(t *testing.T, root, entityType, entityID, dirPath, status, pcID string) {
t.Helper()
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
id := entityType + "_" + entityID + "_" + pcID
_, err := db.Exec(
`INSERT INTO pc_locations(id, entity_type, entity_id, pc_id, dir_path, status) VALUES (?,?,?,?,?,?)`,
id, entityType, entityID, pcID, dirPath, status,
)
if err != nil {
t.Fatalf("insert location: %v", err)
}
}
func insertApp(t *testing.T, root, id, dirPath string) {
t.Helper()
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
defer db.Close()
_, err := db.Exec(`INSERT INTO apps(id, dir_path) VALUES (?,?)`, id, dirPath)
if err != nil {
t.Fatalf("insert app: %v", err)
}
}
func TestPcLocationsDrift_DetectsMissingFolder(t *testing.T) {
root, cleanup := setupTestRegistry(t)
defer cleanup()
// An active entry whose folder does NOT exist on disk
insertLocation(t, root, "app", "my_app", "apps/my_app", "active", "test-pc")
drifts, err := PcLocationsDrift(root, "test-pc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
found := false
for _, d := range drifts {
if d.EntityID == "my_app" && d.Issue == "missing_on_disk" {
found = true
}
}
if !found {
t.Errorf("expected missing_on_disk drift for my_app, got: %+v", drifts)
}
}
func TestPcLocationsDrift_ActiveFolderExistsNoDrift(t *testing.T) {
root, cleanup := setupTestRegistry(t)
defer cleanup()
// Create the folder
appDir := filepath.Join(root, "apps", "good_app")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
insertLocation(t, root, "app", "good_app", "apps/good_app", "active", "test-pc")
drifts, err := PcLocationsDrift(root, "test-pc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, d := range drifts {
if d.EntityID == "good_app" {
t.Errorf("unexpected drift for good_app: %+v", d)
}
}
}
func TestPcLocationsDrift_StatusShouldBeActive(t *testing.T) {
root, cleanup := setupTestRegistry(t)
defer cleanup()
// Folder exists but status is "missing"
appDir := filepath.Join(root, "apps", "came_back")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
insertLocation(t, root, "app", "came_back", "apps/came_back", "missing", "test-pc")
drifts, err := PcLocationsDrift(root, "test-pc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
found := false
for _, d := range drifts {
if d.EntityID == "came_back" && d.Issue == "status_should_be_active" {
found = true
}
}
if !found {
t.Errorf("expected status_should_be_active for came_back, got: %+v", drifts)
}
}
func TestPcLocationsDrift_UntrackedOnDisk(t *testing.T) {
root, cleanup := setupTestRegistry(t)
defer cleanup()
// App indexed in registry with folder on disk but no pc_locations entry
appDir := filepath.Join(root, "apps", "orphan_app")
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatal(err)
}
insertApp(t, root, "orphan_app", "apps/orphan_app")
// No pc_locations entry for test-pc
drifts, err := PcLocationsDrift(root, "test-pc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
found := false
for _, d := range drifts {
if d.EntityID == "orphan_app" && d.Issue == "untracked_on_disk" {
found = true
}
}
if !found {
t.Errorf("expected untracked_on_disk for orphan_app, got: %+v", drifts)
}
}
+134
View File
@@ -0,0 +1,134 @@
package infra
import (
"database/sql"
"fmt"
"net"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
// ServiceStatus holds the runtime status of a registered service app.
type ServiceStatus struct {
AppID string // e.g. "registry_api_go_infra"
Name string // e.g. "registry_api"
UnitName string // e.g. "registry_api.service"
UnitActive string // "active", "inactive", "failed", "not-installed", "unknown"
Port int // declared port parsed from notes/description, 0 if none
PortListening bool // true if Port > 0 and 127.0.0.1:Port is accepting TCP connections
HostMatch string // pc_id from ~/.fn_pc, or "" if unreadable
}
var portRe = regexp.MustCompile(`\b([1-9][0-9]{3,4})\b`)
// ServicesStatus queries registry.db for apps tagged "service" and returns
// their current systemd unit state and port reachability.
func ServicesStatus(registryRoot string) ([]ServiceStatus, error) {
dbPath := registryRoot + "/registry.db"
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&mode=ro")
if err != nil {
return nil, fmt.Errorf("services_status: open db: %w", err)
}
defer db.Close()
rows, err := db.Query(`SELECT id, name, COALESCE(notes,''), COALESCE(description,'') FROM apps WHERE tags LIKE '%service%'`)
if err != nil {
return nil, fmt.Errorf("services_status: query: %w", err)
}
defer rows.Close()
pcID, _ := readFnPC()
var results []ServiceStatus
for rows.Next() {
var id, name, notes, description string
if err := rows.Scan(&id, &name, &notes, &description); err != nil {
continue
}
unit := name + ".service"
active := queryUnitActive(unit)
port := parseFirstPort(notes + " " + description)
listening := false
if port > 0 {
listening = tcpListening("127.0.0.1", port, 500*time.Millisecond)
}
results = append(results, ServiceStatus{
AppID: id,
Name: name,
UnitName: unit,
UnitActive: active,
Port: port,
PortListening: listening,
HostMatch: pcID,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("services_status: rows: %w", err)
}
return results, nil
}
// queryUnitActive runs systemctl is-active, trying --user first then system.
func queryUnitActive(unit string) string {
// try user scope
out, err := exec.Command("systemctl", "--user", "is-active", unit).Output()
if err == nil {
return strings.TrimSpace(string(out))
}
combined, _ := exec.Command("systemctl", "--user", "is-active", unit).CombinedOutput()
if strings.Contains(string(combined), "could not be found") ||
strings.Contains(string(combined), "not found") ||
strings.Contains(string(combined), "No such") {
// try system scope
out2, err2 := exec.Command("systemctl", "is-active", unit).Output()
if err2 == nil {
return strings.TrimSpace(string(out2))
}
combined2, _ := exec.Command("systemctl", "is-active", unit).CombinedOutput()
if strings.Contains(string(combined2), "could not be found") ||
strings.Contains(string(combined2), "not found") ||
strings.Contains(string(combined2), "No such") {
return "not-installed"
}
s := strings.TrimSpace(string(out2))
if s == "" {
return "unknown"
}
return s
}
// systemctl returned non-zero but unit exists (e.g. "inactive", "failed")
if len(out) > 0 {
return strings.TrimSpace(string(out))
}
return "unknown"
}
// parseFirstPort returns the first integer in [1024, 65535] found in text.
func parseFirstPort(text string) int {
for _, m := range portRe.FindAllString(text, -1) {
n, err := strconv.Atoi(m)
if err == nil && n >= 1024 && n <= 65535 {
return n
}
}
return 0
}
// tcpListening attempts a TCP connection to addr:port with the given timeout.
func tcpListening(host string, port int, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
return false
}
conn.Close()
return true
}
+57
View File
@@ -0,0 +1,57 @@
---
name: services_status
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ServicesStatus(registryRoot string) ([]ServiceStatus, error)"
description: "Lista todas las apps registradas con tag 'service' y reporta su estado: unidad systemd activa, puerto escuchando, y pc_id local."
tags: [doctor, systemd, services, health]
uses_functions: []
uses_types:
- error_go_core
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- database/sql
- net
- os
- os/exec
- regexp
- github.com/mattn/go-sqlite3
tested: true
tests:
- "sin apps registradas retorna slice vacio sin error"
- "DB inexistente retorna error"
- "parseFirstPort extrae primer puerto valido"
- "readFnPC ignora comentarios y retorna primer valor"
test_file_path: "functions/infra/services_status_test.go"
file_path: "functions/infra/services_status.go"
params:
- name: registryRoot
desc: "Ruta absoluta al directorio raiz del registry (donde vive registry.db)"
output: "Slice de ServiceStatus con estado systemd, puerto y pc_id por cada app con tag 'service'. Error si no se puede abrir registry.db."
---
## Ejemplo
```go
statuses, err := ServicesStatus("/home/lucas/fn_registry")
if err != nil {
log.Fatal(err)
}
for _, s := range statuses {
fmt.Printf("%s unit=%s port=%d listening=%v\n",
s.Name, s.UnitActive, s.Port, s.PortListening)
}
```
## Notas
- `UnitName` se asume `<name>.service`. Si la app usa otro nombre, el estado sera `not-installed`.
- La heuristica de puerto busca el primer entero en `[1024, 65535]` en `notes` o `description`. Si la app menciona varios numeros, toma el primero que encaje. Puede fallar si `notes` contiene IDs numericos antes que el puerto real.
- `UnitActive` intenta `systemctl --user is-active` primero; si el unit no se encuentra, reintenta con scope de sistema. Valores posibles: `active`, `inactive`, `failed`, `not-installed`, `unknown`.
- `PortListening` hace un dial TCP a `127.0.0.1:<Port>` con timeout 500ms. Solo aplica si `Port > 0`.
- `HostMatch` lee `~/.fn_pc` — primer linea no vacia no comentada.
+90
View File
@@ -0,0 +1,90 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// NOTE: readFnPC is defined in pc_locations_drift.go with signature (string, error).
// createMinimalRegistry creates a temporary registry.db with the apps table
// but no rows, for testing ServicesStatus with an empty dataset.
func createMinimalRegistry(t *testing.T) string {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "registry.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open temp db: %v", err)
}
_, err = db.Exec(`CREATE TABLE apps (id TEXT, name TEXT, tags TEXT, notes TEXT, description TEXT)`)
if err != nil {
t.Fatalf("create table: %v", err)
}
db.Close()
return dir
}
func TestServicesStatus_NoApps(t *testing.T) {
root := createMinimalRegistry(t)
got, err := ServicesStatus(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty slice, got %d entries", len(got))
}
}
func TestServicesStatus_BadDB(t *testing.T) {
_, err := ServicesStatus("/nonexistent/path")
if err == nil {
t.Error("expected error for missing db, got nil")
}
}
func TestServicesStatus_ParseFirstPort(t *testing.T) {
cases := []struct {
text string
want int
}{
{"listens on port 9090 for requests", 9090},
{"API available at :8080", 8080},
{"no port mentioned", 0},
{"low port 80 and high 9000", 9000}, // 80 < 1024, skip; 9000 ok
{"port 65535 max", 65535},
}
for _, c := range cases {
got := parseFirstPort(c.text)
if got != c.want {
t.Errorf("parseFirstPort(%q) = %d, want %d", c.text, got, c.want)
}
}
}
func TestServicesStatus_ReadFnPC(t *testing.T) {
dir := t.TempDir()
pcFile := filepath.Join(dir, ".fn_pc")
// readFnPC returns the first non-empty line (no comment stripping)
if err := os.WriteFile(pcFile, []byte("home-wsl\n"), 0o644); err != nil {
t.Fatal(err)
}
// Temporarily override HOME so readFnPC picks up our file
old := os.Getenv("HOME")
t.Cleanup(func() { os.Setenv("HOME", old) })
os.Setenv("HOME", dir)
got, err2 := readFnPC()
if err2 != nil {
t.Fatalf("readFnPC error: %v", err2)
}
if got != "home-wsl" {
t.Errorf("readFnPC() = %q, want %q", got, "home-wsl")
}
}
+1 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/marcboeker/go-duckdb v1.8.5
github.com/mattn/go-sqlite3 v1.14.37
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17
@@ -62,7 +63,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/text v0.36.0 // indirect
-2
View File
@@ -155,8 +155,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=