fix(infra): gradle_run detecta android-sdk — issue 0076 #2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
```
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¬es, &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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user