Compare commits
30 Commits
auto/0129
...
3b348670cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b348670cc | |||
| fc4180cbb3 | |||
| 7eef442444 | |||
| 876020addf | |||
| 469e40ba40 | |||
| fce88032ca | |||
| fec8ebd4ec | |||
| f5f05e4624 | |||
| 532f3d0ea8 | |||
| fe65c5e527 | |||
| de9bfec498 | |||
| e9c64a4687 | |||
| 70ec825e32 | |||
| 22692c1ed2 | |||
| d128ad89ac | |||
| bd9f0d8437 | |||
| 207c08c3b7 | |||
| 01bc2aeb14 | |||
| 9ec7751f6f | |||
| fef86250a0 | |||
| 472b6092bb | |||
| ea5c94fc8a | |||
| a8b09ad154 | |||
| 6aa874f2b6 | |||
| 93352a7780 | |||
| 0ffae6daa4 | |||
| 74b58cf0d0 | |||
| 9752fb106a | |||
| 8cb0121573 | |||
| 90115270d2 |
@@ -42,10 +42,10 @@ Opcionalmente:
|
||||
|
||||
```bash
|
||||
# Por id
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
|
||||
|
||||
# Por dir_path
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
|
||||
```
|
||||
|
||||
Si no hay match → reportar y abortar.
|
||||
@@ -78,8 +78,8 @@ APP_DB="$APP_DIR/operations.db"
|
||||
|
||||
# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs)
|
||||
if [ ! -f "$APP_DB" ]; then
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init "$APP_DIR"
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init "$APP_DIR"
|
||||
fi
|
||||
|
||||
# Verificar tabla e2e_runs existe (migracion 005)
|
||||
@@ -97,7 +97,7 @@ Hay dos caminos:
|
||||
**Camino A — invocar funcion del registry (preferido):**
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
./fn run e2e_run_checks_go_infra ...
|
||||
```
|
||||
|
||||
@@ -139,15 +139,15 @@ func main() {
|
||||
|
||||
Ejecutar con:
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
CGO_ENABLED=1 go run -tags fts5 /tmp/run_e2e_<id>.go /tmp/checks.yaml "$APP_DIR"
|
||||
```
|
||||
|
||||
### 5. Eval assertions activas (si la app las tiene)
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db "$APP_DB"
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db "$APP_DB"
|
||||
```
|
||||
|
||||
Capturar fallos como warning checks adicionales.
|
||||
|
||||
@@ -15,20 +15,20 @@ Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y t
|
||||
|
||||
```bash
|
||||
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Buscar tipos existentes
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Ver funciones de un dominio
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
|
||||
# Ver tipos de un dominio
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
|
||||
|
||||
# Verificar que un ID referenciado existe
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
|
||||
```
|
||||
|
||||
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
|
||||
@@ -39,13 +39,13 @@ Antes de implementar logica desde cero, busca funciones del registry que puedas
|
||||
|
||||
```bash
|
||||
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
|
||||
|
||||
# Ver que retorna y que tipos usa una funcion candidata
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
|
||||
|
||||
# Buscar funciones puras del mismo dominio (las mas componibles)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
|
||||
```
|
||||
|
||||
**Criterios de reutilizacion:**
|
||||
@@ -78,38 +78,38 @@ Esto acelera la construccion y fortalece el grafo de dependencias del registry.
|
||||
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
|
||||
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
|
||||
|
||||
**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md.
|
||||
**Ruta absoluta donde crear el archivo** = `$HOME/fn_registry/` + `file_path` del .md.
|
||||
|
||||
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
|
||||
- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
||||
- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh`
|
||||
- `$HOME/fn_registry/bash/functions/infra/{name}.sh` + `.md`
|
||||
- **NUNCA** en `$HOME/fn_registry/functions/infra/{name}.sh`
|
||||
|
||||
### Estructura detallada
|
||||
|
||||
**Go** (carpeta raiz: `functions/` y `types/`)
|
||||
- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go`
|
||||
- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
||||
- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md`
|
||||
- Funciones: `$HOME/fn_registry/functions/{domain}/{name}.go` + `.md`
|
||||
- Tests: `$HOME/fn_registry/functions/{domain}/{name}_test.go`
|
||||
- Tipos: `$HOME/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `$HOME/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
|
||||
- Pipelines: `$HOME/fn_registry/functions/pipelines/{name}.go` + `.md`
|
||||
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
|
||||
|
||||
**Python** (carpeta raiz: `python/`)
|
||||
- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py`
|
||||
- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
||||
- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
||||
- Funciones: `$HOME/fn_registry/python/functions/{domain}/{name}.py` + `.md`
|
||||
- Tests: `$HOME/fn_registry/python/functions/{domain}/{name}_test.py`
|
||||
- Tipos: `$HOME/fn_registry/python/types/{domain}/{name}.py` + `.md`
|
||||
- Pipelines: `$HOME/fn_registry/python/functions/pipelines/{name}.py` + `.md`
|
||||
|
||||
**Bash** (carpeta raiz: `bash/`)
|
||||
- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
||||
- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
||||
- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
||||
- Funciones: `$HOME/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
|
||||
- Tests: `$HOME/fn_registry/bash/functions/{domain}/{name}_test.sh`
|
||||
- Pipelines: `$HOME/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
|
||||
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
|
||||
|
||||
**TypeScript** (carpeta raiz: `frontend/`)
|
||||
- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
||||
- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
||||
- Funciones puras: `$HOME/fn_registry/frontend/functions/core/{name}.ts` + `.md`
|
||||
- Componentes React: `$HOME/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
|
||||
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
|
||||
- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
||||
- Tipos: `$HOME/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -591,7 +591,7 @@ Documentar completamente el .md igualmente.
|
||||
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
|
||||
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
|
||||
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
|
||||
4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index`
|
||||
4. **INDEXAR** ejecutando: `cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index`
|
||||
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
|
||||
6. Si hay errores de validacion, corregirlos y re-indexar
|
||||
|
||||
@@ -600,10 +600,10 @@ Documentar completamente el .md igualmente.
|
||||
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
|
||||
2. **CREAR** el archivo de test
|
||||
3. **EJECUTAR** los tests:
|
||||
- Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
||||
- Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
||||
- Go: `cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
|
||||
- Python: `cd $HOME/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
|
||||
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
|
||||
- Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
||||
- Bash: `cd $HOME/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
|
||||
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
|
||||
5. **RE-INDEXAR** y verificar
|
||||
|
||||
@@ -620,19 +620,19 @@ Documentar completamente el .md igualmente.
|
||||
|
||||
```bash
|
||||
# Compilar CLI (necesario si se modifico codigo del CLI)
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
|
||||
|
||||
# Indexar registry
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
|
||||
# Tests Go de un dominio
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
|
||||
|
||||
# Tests Go de todo el registry
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
|
||||
|
||||
# Mostrar funcion indexada
|
||||
cd /home/lucas/fn_registry && ./fn show {id}
|
||||
cd $HOME/fn_registry && ./fn show {id}
|
||||
```
|
||||
|
||||
### fn run — Ejecutar funciones y pipelines directamente
|
||||
@@ -640,7 +640,7 @@ cd /home/lucas/fn_registry && ./fn show {id}
|
||||
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Go pipeline (go run . en su directorio)
|
||||
./fn run init_metabase --project test
|
||||
@@ -729,7 +729,7 @@ Peticion: "Crea una funcion que calcule la media de un slice de float64"
|
||||
|
||||
### Paso 1: Buscar en BD
|
||||
```bash
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
|
||||
```
|
||||
|
||||
### Paso 2: Crear archivos
|
||||
@@ -823,6 +823,6 @@ func TestMean(t *testing.T) {
|
||||
|
||||
### Paso 3: Indexar y verificar
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
|
||||
./fn show mean_go_core
|
||||
```
|
||||
|
||||
@@ -35,22 +35,22 @@ Las apps estan indexadas en registry.db con toda la metadata necesaria para ejec
|
||||
|
||||
```bash
|
||||
# Ver todas las apps disponibles
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
|
||||
|
||||
# Ver app completa con dependencias y framework
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
|
||||
|
||||
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Apps de un dominio
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
|
||||
|
||||
# Apps que usan una funcion especifica
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
|
||||
|
||||
# Ver documentacion completa de una app
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
|
||||
```
|
||||
|
||||
**Campos clave de apps para ejecucion:**
|
||||
@@ -65,19 +65,19 @@ sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM ap
|
||||
|
||||
```bash
|
||||
# Ver pipeline/funcion completa
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
|
||||
|
||||
# Ver codigo de la funcion
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
|
||||
|
||||
# Pipelines disponibles (con tag launcher para TUI)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
|
||||
|
||||
# Funciones impuras ejecutables directamente
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
|
||||
|
||||
# Buscar por FTS
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
```
|
||||
|
||||
### Usar contexto de apps para ejecucion inteligente
|
||||
@@ -98,10 +98,10 @@ Cuando te pidan ejecutar una app, sigue este flujo:
|
||||
|
||||
```bash
|
||||
# Desde la raiz del registry
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Opcion A: Usar el CLI
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# Opcion B: Copiar template directamente
|
||||
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
|
||||
@@ -221,10 +221,10 @@ Las entities representan los datos concretos del proyecto. Las relations documen
|
||||
### Crear entities (datos que el pipeline consume o produce)
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Entity de entrada
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "btc_ticks" \
|
||||
--type-ref "tick_go_finance" \
|
||||
@@ -235,7 +235,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
|
||||
|
||||
# Entity de salida
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "btc_ohlcv_5m" \
|
||||
--type-ref "ohlcv_go_finance" \
|
||||
@@ -249,7 +249,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
|
||||
### Crear relations (como se conectan entities)
|
||||
|
||||
```bash
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--name "ticks_to_ohlcv" \
|
||||
--from-entity "{entity_id}" \
|
||||
@@ -262,13 +262,13 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
|
||||
|
||||
```bash
|
||||
# Listar entities
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
|
||||
|
||||
# Listar relations
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
|
||||
|
||||
# Ver grafo ASCII
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
```
|
||||
|
||||
---
|
||||
@@ -280,7 +280,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/ope
|
||||
`fn run` despacha automaticamente segun el lenguaje y tipo:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Go pipeline (go run . en su directorio)
|
||||
./fn run init_metabase --project test
|
||||
@@ -318,13 +318,13 @@ Para apps con su propio main.go/main.py/main.sh:
|
||||
|
||||
```bash
|
||||
# Go app
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
||||
cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
|
||||
|
||||
# Python app
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args]
|
||||
cd $HOME/fn_registry/apps/{app_name} && python3 main.py [args]
|
||||
|
||||
# Bash app
|
||||
cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args]
|
||||
cd $HOME/fn_registry/apps/{app_name} && bash main.sh [args]
|
||||
```
|
||||
|
||||
### Capturar metricas de ejecucion
|
||||
@@ -340,7 +340,7 @@ Al ejecutar, siempre captura:
|
||||
```bash
|
||||
# Ejemplo: ejecutar con captura de tiempo
|
||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
||||
OUTPUT=$(cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
|
||||
EXIT_CODE=$?
|
||||
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
@@ -362,7 +362,7 @@ echo "Status: $STATUS | Start: $START | End: $END"
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--pipeline-id "tick_to_ohlcv_go_finance" \
|
||||
--relation-id "{relation_id}" \
|
||||
@@ -396,16 +396,16 @@ sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id,
|
||||
|
||||
```bash
|
||||
# Listar todas
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
|
||||
|
||||
# Por pipeline
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
|
||||
|
||||
# Por status
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
|
||||
|
||||
# Detalle de una ejecucion
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -441,12 +441,12 @@ Si hay assertions definidas sobre las entities afectadas, evaluarlas para verifi
|
||||
|
||||
```bash
|
||||
# Evaluar assertions de una entity
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--entity-id "ENTITY_ID"
|
||||
|
||||
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
|
||||
--db apps/{app_name}/operations.db \
|
||||
--entity-id "ENTITY_ID" \
|
||||
--react
|
||||
@@ -467,10 +467,10 @@ Cuando el usuario pide ejecutar algo que aun no tiene app:
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: go
|
||||
@@ -490,7 +490,7 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
@@ -499,7 +499,7 @@ build/
|
||||
GIEOF
|
||||
|
||||
# 4. Inicializar modulo Go
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
go mod init fn_registry/apps/{app_name}
|
||||
|
||||
# 5. Crear main.go minimo
|
||||
@@ -523,8 +523,8 @@ func main() {
|
||||
GOEOF
|
||||
|
||||
# 6. Inicializar operations.db
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 7. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -534,10 +534,10 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: py
|
||||
@@ -557,7 +557,7 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
@@ -565,7 +565,7 @@ __pycache__/
|
||||
GIEOF
|
||||
|
||||
# 4. Crear main.py
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/main.py << 'PYEOF'
|
||||
"""Pipeline executor."""
|
||||
import sys
|
||||
import time
|
||||
@@ -584,8 +584,8 @@ if __name__ == "__main__":
|
||||
PYEOF
|
||||
|
||||
# 5. Inicializar operations.db
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 6. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -595,10 +595,10 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
```bash
|
||||
# 1. Crear directorio
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
|
||||
# 2. Crear app.md (OBLIGATORIO)
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
|
||||
---
|
||||
name: {app_name}
|
||||
lang: bash
|
||||
@@ -618,14 +618,14 @@ dir_path: "apps/{app_name}"
|
||||
MDEOF
|
||||
|
||||
# 3. Crear .gitignore
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
|
||||
operations.db
|
||||
operations.db-wal
|
||||
operations.db-shm
|
||||
GIEOF
|
||||
|
||||
# 4. Crear main.sh
|
||||
cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
||||
cat > $HOME/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
|
||||
#!/usr/bin/env bash
|
||||
# Pipeline executor: {app_name}
|
||||
set -euo pipefail
|
||||
@@ -650,11 +650,11 @@ main() {
|
||||
|
||||
main "$@"
|
||||
SHEOF
|
||||
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
||||
|
||||
# 5. Inicializar operations.db
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# 6. Indexar en registry.db
|
||||
./fn index
|
||||
@@ -669,7 +669,7 @@ Este patron captura todo lo necesario para registrar la ejecucion:
|
||||
### Go
|
||||
|
||||
```bash
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
PIPELINE_ID="{pipeline_id}"
|
||||
RELATION_ID="{relation_id}" # vacio si no aplica
|
||||
@@ -689,8 +689,8 @@ else
|
||||
fi
|
||||
|
||||
# Registrar ejecucion
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "$PIPELINE_ID" \
|
||||
--status "$STATUS" \
|
||||
@@ -704,7 +704,7 @@ rm -f "$STDOUT_FILE" "$STDERR_FILE"
|
||||
### Python
|
||||
|
||||
```bash
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
|
||||
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
@@ -716,8 +716,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
STATUS="success"
|
||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "{pipeline_id}" \
|
||||
--status "$STATUS" \
|
||||
@@ -728,7 +728,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
### Bash
|
||||
|
||||
```bash
|
||||
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
|
||||
APP_DIR="$HOME/fn_registry/apps/{app_name}"
|
||||
OPS_DB="$APP_DIR/operations.db"
|
||||
PIPELINE_ID="{pipeline_id}"
|
||||
|
||||
@@ -741,8 +741,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
STATUS="success"
|
||||
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
|
||||
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
|
||||
--db "$OPS_DB" \
|
||||
--pipeline-id "$PIPELINE_ID" \
|
||||
--status "$STATUS" \
|
||||
@@ -758,10 +758,10 @@ Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al
|
||||
|
||||
```bash
|
||||
# Verificar snapshots
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
|
||||
# Actualizar si estan desactualizados
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -800,7 +800,7 @@ Crea una proposal cuando detectes:
|
||||
### Como crear proposals
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Proposal para nueva funcion
|
||||
./fn proposal add \
|
||||
@@ -840,7 +840,7 @@ Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evi
|
||||
|
||||
```bash
|
||||
# Obtener el ID de la ejecucion que evidencia el problema
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list \
|
||||
--db apps/{app_name}/operations.db --status failure
|
||||
|
||||
# Incluir evidencia en la descripcion
|
||||
@@ -858,19 +858,19 @@ Usa el contexto de la tabla apps para comparar y detectar patrones:
|
||||
|
||||
```bash
|
||||
# Ver que funciones usan las apps — detectar patrones comunes
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
|
||||
|
||||
# Ver funciones mas usadas por apps (candidatas a mejora)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
sqlite3 $HOME/fn_registry/registry.db "
|
||||
SELECT f.value as func_id, COUNT(*) as uso
|
||||
FROM apps, json_each(apps.uses_functions) f
|
||||
GROUP BY f.value ORDER BY uso DESC;"
|
||||
|
||||
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
|
||||
|
||||
# Ver si ya existe una proposal para algo similar
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
|
||||
```
|
||||
|
||||
### Flujo de deteccion al ejecutar
|
||||
|
||||
@@ -43,12 +43,12 @@ APP_ID="<input>"
|
||||
RUN_ID="<input>"
|
||||
|
||||
# dir_path desde registry
|
||||
DIR_PATH=$(sqlite3 /home/lucas/fn_registry/registry.db \
|
||||
DIR_PATH=$(sqlite3 $HOME/fn_registry/registry.db \
|
||||
"SELECT dir_path FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
||||
APP_ID=$(sqlite3 /home/lucas/fn_registry/registry.db \
|
||||
APP_ID=$(sqlite3 $HOME/fn_registry/registry.db \
|
||||
"SELECT id FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
|
||||
|
||||
APP_DB="/home/lucas/fn_registry/$DIR_PATH/operations.db"
|
||||
APP_DB="$HOME/fn_registry/$DIR_PATH/operations.db"
|
||||
[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db"
|
||||
|
||||
# Sanity check
|
||||
@@ -93,7 +93,7 @@ Por cada fallo:
|
||||
Antes de crear proposal, verificar que no haya una identica abierta:
|
||||
|
||||
```bash
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
sqlite3 $HOME/fn_registry/registry.db "
|
||||
SELECT id FROM proposals
|
||||
WHERE status = 'pending'
|
||||
AND target_id = '$APP_ID'
|
||||
@@ -139,7 +139,7 @@ Sugerencia generica en `description` (NO codigo concreto, solo direccion):
|
||||
Si la misma assertion/check ha disparado proposal mas de 3 veces en los ultimos 30 dias, marcar `priority` (campo extendido si existe, si no, anotar en `description: '[REINCIDENTE x4]'`).
|
||||
|
||||
```bash
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "
|
||||
sqlite3 $HOME/fn_registry/registry.db "
|
||||
SELECT COUNT(*) FROM proposals
|
||||
WHERE target_id = '$APP_ID'
|
||||
AND title LIKE '%::$CHECK_ID%'
|
||||
|
||||
@@ -30,14 +30,14 @@ Referencia completa: `dev/issues/0069-autonomous-agent-loop-self-iterating-tasks
|
||||
6. **Auditoria total**. Cada decision se loggea en `task_runs.progress_json` con razonamiento + fase + run_id.
|
||||
7. **No self-modify**. NO modificas tu propio SKILL.md ni el de otros subagentes en la misma run.
|
||||
8. **Cero produccion**. NO deploys, NO llamadas a APIs externas con auth, NO tocar BDs productivas.
|
||||
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `/home/lucas/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
|
||||
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `/home/lucas/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
|
||||
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `$HOME/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
|
||||
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `$HOME/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
|
||||
a. Aplicar el fix del hook EN EL WORKTREE y commitearlo en `auto/*` — al mergear el PR, main heredara el fix.
|
||||
b. Si el hook bloquea progreso y el fix del hook excede tu scope, `git commit --no-verify` para ESE commit SOLO, documentando excepcion en `task_runs.events_json[].decision="skip_hook"` con razon.
|
||||
NO modificar archivos en main directamente.
|
||||
11. **Post-iteracion sanity check**. Tras cada commit en `auto/*`, verificar:
|
||||
```bash
|
||||
git -C /home/lucas/fn_registry status --short
|
||||
git -C $HOME/fn_registry status --short
|
||||
```
|
||||
Si la salida cambia respecto al baseline (capturado al inicio del piloto), HAS contaminado el repo principal. ABORT con `status=sandbox_breach` y reporta los archivos afectados en el output al humano.
|
||||
|
||||
@@ -49,24 +49,24 @@ Antes de arrancar el bucle, comprobar:
|
||||
|
||||
```bash
|
||||
# 1. Migration 006_task_runs.sql existe
|
||||
ls /home/lucas/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
||||
ls $HOME/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|
||||
|| { echo "ABORT: migration 006_task_runs.sql ausente. Aplicar issue 0069 paso 1 antes."; exit 2; }
|
||||
|
||||
# 2. Subagentes fn-* presentes
|
||||
for a in fn-constructor fn-executor fn-recopilador fn-analizador fn-mejorador; do
|
||||
test -f /home/lucas/fn_registry/.claude/agents/$a/SKILL.md \
|
||||
test -f $HOME/fn_registry/.claude/agents/$a/SKILL.md \
|
||||
|| { echo "ABORT: subagente $a ausente"; exit 2; }
|
||||
done
|
||||
|
||||
# 3. master local up-to-date con origin (worktree se creara desde master)
|
||||
git -C /home/lucas/fn_registry fetch origin master --quiet
|
||||
LOCAL=$(git -C /home/lucas/fn_registry rev-parse master)
|
||||
REMOTE=$(git -C /home/lucas/fn_registry rev-parse origin/master)
|
||||
git -C $HOME/fn_registry fetch origin master --quiet
|
||||
LOCAL=$(git -C $HOME/fn_registry rev-parse master)
|
||||
REMOTE=$(git -C $HOME/fn_registry rev-parse origin/master)
|
||||
test "$LOCAL" = "$REMOTE" \
|
||||
|| { echo "ABORT: master local desincronizado con origin. git pull antes."; exit 2; }
|
||||
|
||||
# 4. Branch auto/<issue> NO existe ya (ni local ni en worktrees)
|
||||
git -C /home/lucas/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
||||
git -C $HOME/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
|
||||
&& { echo "ABORT: branch auto/${ISSUE_ID} ya existe. Limpiar antes (git branch -D + worktree remove)."; exit 2; }
|
||||
|
||||
# 5. gh CLI autenticado (necesario para PR draft al converger)
|
||||
@@ -116,7 +116,7 @@ BRANCH="auto/${ISSUE_ID}"
|
||||
TASK_RUN_ID="task_$(openssl rand -hex 8)"
|
||||
STARTED_AT=$(date +%s)
|
||||
WT_ROOT="/tmp/fn_orq_${ISSUE_ID}_${STARTED_AT}"
|
||||
REPO="/home/lucas/fn_registry"
|
||||
REPO="$HOME/fn_registry"
|
||||
|
||||
# Crear worktree aislado desde master (no toca el principal)
|
||||
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
|
||||
@@ -187,13 +187,13 @@ while iter < max_iterations and elapsed < max_minutes:
|
||||
|
||||
Usar `Agent` tool con `subagent_type` correcto. Prompt **autocontenido** (paths absolutos, IDs, criterio exito).
|
||||
|
||||
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `/home/lucas/fn_registry/`.
|
||||
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `$HOME/fn_registry/`.
|
||||
|
||||
Patron prompt:
|
||||
```
|
||||
Working dir: <WT_ROOT> # NO /home/lucas/fn_registry
|
||||
Working dir: <WT_ROOT> # NO $HOME/fn_registry
|
||||
Branch: auto/<issue_id>
|
||||
Repo principal (solo lectura para registry.db): /home/lucas/fn_registry
|
||||
Repo principal (solo lectura para registry.db): $HOME/fn_registry
|
||||
...
|
||||
```
|
||||
|
||||
@@ -346,7 +346,7 @@ Cada `progress_json` entry:
|
||||
|---|---|---|
|
||||
| `task_runs` no existe | migration 006 no aplicada | abortar pre-condicion 1 |
|
||||
| `worktree add` falla con "already exists" | branch o dir previo no limpiado | `git worktree prune` + `git branch -D auto/<id>`, reintentar |
|
||||
| Subagente toca `/home/lucas/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
||||
| Subagente toca `$HOME/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
|
||||
| `master` desincronizado con origin | falta `git pull` | abortar pre-condicion 3 |
|
||||
| Loop infinito (mismo fail siempre) | watchdog ausente o desactivado | watchdog OBLIGATORIO, no skipear |
|
||||
| Subagente devuelve output ambiguo | prompt insuficiente | rebriefing con paths/IDs explicitos |
|
||||
|
||||
@@ -40,10 +40,10 @@ apps/{app_name}/
|
||||
|
||||
```bash
|
||||
# Listar todas las apps
|
||||
ls -d /home/lucas/fn_registry/apps/*/
|
||||
ls -d $HOME/fn_registry/apps/*/
|
||||
|
||||
# Verificar que cada app tiene app.md
|
||||
for app in /home/lucas/fn_registry/apps/*/; do
|
||||
for app in $HOME/fn_registry/apps/*/; do
|
||||
name=$(basename "$app")
|
||||
echo "=== $name ==="
|
||||
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
|
||||
@@ -82,8 +82,8 @@ sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/nul
|
||||
**Si faltan tablas**, aplicar migraciones:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
```
|
||||
|
||||
### 3. Integridad de Entities
|
||||
@@ -96,7 +96,7 @@ sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entiti
|
||||
|
||||
# Validar que type_ref existe en registry.db
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: type_ref '$ref' no existe en registry.db"
|
||||
fi
|
||||
@@ -129,7 +129,7 @@ sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_
|
||||
|
||||
# Validar que 'via' referencia una funcion/pipeline del registry
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: relation.via '$via' no existe en registry.db"
|
||||
fi
|
||||
@@ -156,7 +156,7 @@ sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, reco
|
||||
|
||||
# Validar que pipeline_id existe en registry.db
|
||||
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
|
||||
fi
|
||||
@@ -230,7 +230,7 @@ sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_sn
|
||||
|
||||
# Comparar con registry.db — detectar snapshots desactualizados
|
||||
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
|
||||
REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
||||
REG_VER=$(sqlite3 $HOME/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
|
||||
if [ -z "$REG_VER" ]; then
|
||||
echo "WARN: snapshot '$id' ya no existe en registry.db"
|
||||
elif [ "$ver" != "$REG_VER" ]; then
|
||||
@@ -252,14 +252,14 @@ done
|
||||
|
||||
```bash
|
||||
# Verificar que la app esta en registry.db
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
|
||||
|
||||
# Verificar que uses_functions del app.md coincide con lo indexado
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
|
||||
|
||||
# Verificar que todas las funciones referenciadas existen
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
||||
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
|
||||
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
|
||||
if [ -z "$EXISTS" ]; then
|
||||
echo "ERROR: app usa funcion '$fid' que no existe en registry"
|
||||
fi
|
||||
@@ -273,7 +273,7 @@ done
|
||||
Patron para auditar TODAS las apps de una vez:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
echo "========================================="
|
||||
echo "AUDITORIA DE APPS — fn-recopilador"
|
||||
@@ -327,7 +327,7 @@ for app_dir in apps/*/; do
|
||||
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
|
||||
|
||||
# 9. App indexada en registry.db
|
||||
INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
||||
INDEXED=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
|
||||
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
|
||||
done
|
||||
|
||||
@@ -393,25 +393,25 @@ echo "========================================="
|
||||
El recopilador puede sugerir o ejecutar estas reparaciones:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Aplicar migraciones faltantes
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
|
||||
# Actualizar snapshot desactualizado
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
|
||||
|
||||
# Verificar snapshots
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
|
||||
|
||||
# Evaluar assertions pendientes
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
|
||||
|
||||
# Re-indexar para que la app aparezca en registry.db
|
||||
./fn index
|
||||
|
||||
# Ver grafo de la app (util para diagnostico visual)
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+13
-13
@@ -38,13 +38,13 @@ Antes de crear nada, recopilar contexto:
|
||||
|
||||
```bash
|
||||
# Buscar funciones relevantes por descripcion
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Buscar apps similares
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Verificar que el nombre no esta tomado
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
|
||||
```
|
||||
|
||||
4. **Presentar plan al usuario** antes de ejecutar:
|
||||
@@ -79,7 +79,7 @@ Usar el Agent tool con `subagent_type: "fn-constructor"` pasando:
|
||||
Despues de que fn-constructor termine, verificar que todo se indexo:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
# Verificar cada funcion creada
|
||||
./fn show {id_de_cada_funcion}
|
||||
```
|
||||
@@ -91,7 +91,7 @@ cd /home/lucas/fn_registry && ./fn index
|
||||
### Estructura base (todos los lenguajes)
|
||||
|
||||
```bash
|
||||
mkdir -p /home/lucas/fn_registry/apps/{app_name}
|
||||
mkdir -p $HOME/fn_registry/apps/{app_name}
|
||||
```
|
||||
|
||||
### app.md (OBLIGATORIO — siempre primero)
|
||||
@@ -143,7 +143,7 @@ build/
|
||||
|
||||
**Go (CLI/TUI):**
|
||||
```bash
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
go mod init fn_registry/apps/{app_name}
|
||||
# Crear main.go, app/, config/, views/ segun necesidad
|
||||
```
|
||||
@@ -151,7 +151,7 @@ go mod init fn_registry/apps/{app_name}
|
||||
**Go (Wails — desktop con UI):**
|
||||
```bash
|
||||
# Usar scaffold del registry
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
|
||||
```
|
||||
|
||||
@@ -165,20 +165,20 @@ cd /home/lucas/fn_registry
|
||||
```bash
|
||||
# Crear main.sh con source a funciones del registry
|
||||
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
|
||||
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
|
||||
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
|
||||
```
|
||||
|
||||
### Inicializar operations.db
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
|
||||
cd $HOME/fn_registry
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
|
||||
```
|
||||
|
||||
### Indexar en registry.db
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
# Verificar
|
||||
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
|
||||
```
|
||||
@@ -241,7 +241,7 @@ Usar el Agent tool con `subagent_type: "gitea"` pasando:
|
||||
```bash
|
||||
# 1. Crear repo en Gitea (via API)
|
||||
# 2. Inicializar git en la app
|
||||
cd /home/lucas/fn_registry/apps/{app_name}
|
||||
cd $HOME/fn_registry/apps/{app_name}
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Initial commit: {app_name} — {descripcion}"
|
||||
@@ -256,7 +256,7 @@ git push -u origin master
|
||||
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+59
-22
@@ -1,37 +1,74 @@
|
||||
# /compile — Compila app C++ y la copia al escritorio de Windows
|
||||
---
|
||||
description: "Compila app del registry (C++ o Wails Go), copia el .exe a Desktop/apps/<app>/ y relanza en Windows. Wrapper sobre compile_cpp_app o compile_wails_app segun framework declarado en app.md."
|
||||
---
|
||||
|
||||
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/`).
|
||||
# /compile — Compila app C++ o Wails y la copia al escritorio de Windows
|
||||
|
||||
Wrapper sobre 2 pipelines del registry segun el framework:
|
||||
|
||||
- **C++ (imgui / cmake)** → `compile_cpp_app_bash_pipelines`. Cross-compile MinGW + assets/enrichers/runtime + taskkill, NO relanza.
|
||||
- **Wails Go (matrix_client_pc, matrix_admin_panel, etc.)** → `compile_wails_app_bash_pipelines`. `wails build -platform windows/amd64` con `-tags goolm` si E2EE + taskkill + **RELANZA** la app tras copy.
|
||||
|
||||
Toda la logica vive en el registry (resolver app desde CWD/arg, build, deploy con preservacion de `local_files/`).
|
||||
|
||||
## Dispatch
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Detecta framework via wails.json o CMakeLists.txt en el dir del app
|
||||
APP="$ARGUMENTS"
|
||||
RESOLVED=$(bash -c '
|
||||
source bash/functions/infra/resolve_cpp_app_dir.sh
|
||||
resolve_cpp_app_dir "'"$APP"'"
|
||||
' 2>/dev/null) || true
|
||||
APP_DIR="$(echo "$RESOLVED" | cut -f2)"
|
||||
|
||||
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/wails.json" ]; then
|
||||
./fn run compile_wails_app "$ARGUMENTS"
|
||||
elif [ -n "$APP_DIR" ] && [ -f "$APP_DIR/CMakeLists.txt" ]; then
|
||||
./fn run compile_cpp_app "$ARGUMENTS"
|
||||
else
|
||||
echo "ERROR: no se detecto framework (falta wails.json o CMakeLists.txt en $APP_DIR)" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Argumento
|
||||
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
|
||||
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`, `matrix_client_pc`).
|
||||
|
||||
- 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.
|
||||
- Sin argumento: deduce desde `pwd` si estas dentro de `cpp/apps/<X>/`, `apps/<X>/` o `projects/*/apps/<X>/`.
|
||||
- Si no se puede deducir y no se pasa argumento, lista las apps disponibles en stderr y aborta.
|
||||
|
||||
## Qué hace el pipeline
|
||||
## Que hace el pipeline (C++)
|
||||
|
||||
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).
|
||||
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>`.
|
||||
2. Verifica `CMakeLists.txt`.
|
||||
3. `build_cpp_windows_bash_infra <app>` — cross-compila con MinGW.
|
||||
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.
|
||||
- `taskkill.exe /IM <app>.exe /F`.
|
||||
- Copia `<app>.exe` + DLLs.
|
||||
- rsync `assets/`, `enrichers/`, `runtime/` (si aplica).
|
||||
- Preserva `local_files/`.
|
||||
- **NO** relanza.
|
||||
|
||||
## Que hace el pipeline (Wails)
|
||||
|
||||
1. `resolve_cpp_app_dir_bash_infra` (reusado — sirve para Wails apps tambien).
|
||||
2. Verifica `wails.json` + `go.mod`.
|
||||
3. Detecta `-tags goolm` automaticamente (grep `matrix_crypto_init` en `app.md` o `build:tags` en `wails.json`).
|
||||
4. `wails build -platform windows/amd64 [-tags goolm]`.
|
||||
5. `deploy_wails_exe_to_windows_bash_infra <app> <dir>`:
|
||||
- `taskkill.exe /IM <app>.exe /F`.
|
||||
- Copia `<app>.exe` (+ `appicon.ico` si existe).
|
||||
- **Relanza** via `cmd.exe /c start "" <app>.exe`.
|
||||
- Preserva `local_files/`.
|
||||
|
||||
## Notas
|
||||
|
||||
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
|
||||
- Solo target Windows hoy. Linux ya lo da `wails build` / `cpp/build/` nativo.
|
||||
- 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.
|
||||
- Si la app C++ no esta registrada en `cpp/CMakeLists.txt`, el build falla — registrar siguiendo `.claude/rules/cpp_apps.md` §5.
|
||||
- Si la app Wails falla build con `no required module provides package`, correr `go mod tidy` en el dir del app primero.
|
||||
- Para tocar la logica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,build_cpp_windows,deploy_{cpp,wails}_exe_to_windows,compile_{cpp,wails}_app}.sh`, no este wrapper.
|
||||
|
||||
@@ -23,8 +23,8 @@ Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas co
|
||||
### Paso 0 — verificar que no existe
|
||||
|
||||
```bash
|
||||
test -d "/home/lucas/fn_registry/apps/<name>" \
|
||||
|| ls /home/lucas/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||
test -d "$HOME/fn_registry/apps/<name>" \
|
||||
|| ls $HOME/fn_registry/projects/*/apps/<name> 2>/dev/null
|
||||
```
|
||||
|
||||
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
|
||||
@@ -42,7 +42,7 @@ Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE junt
|
||||
|
||||
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
|
||||
```bash
|
||||
ls /home/lucas/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||
ls $HOME/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
|
||||
```
|
||||
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
|
||||
6. **icon.accent** hex `#rrggbb` (palette select):
|
||||
@@ -122,7 +122,7 @@ Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffo
|
||||
Una vez confirmado:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# 1. Scaffolder
|
||||
./fn run init_cpp_app <name> \
|
||||
@@ -178,7 +178,7 @@ cd /home/lucas/fn_registry
|
||||
|
||||
```bash
|
||||
# Buscar apps/<name>/ o projects/*/apps/<name>/
|
||||
sqlite3 /home/lucas/fn_registry/registry.db \
|
||||
sqlite3 $HOME/fn_registry/registry.db \
|
||||
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
|
||||
```
|
||||
|
||||
@@ -211,7 +211,7 @@ Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write`
|
||||
|
||||
```bash
|
||||
# Siempre
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
|
||||
# Si toco icon.* -> regenerar appicon
|
||||
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
|
||||
|
||||
@@ -38,19 +38,19 @@ Consultar `registry.db` para encontrar funciones existentes relevantes y evitar
|
||||
|
||||
```bash
|
||||
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
|
||||
|
||||
# Buscar tipos relacionados
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
|
||||
|
||||
# Funciones del dominio objetivo
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
|
||||
|
||||
# Tipos del dominio objetivo
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
|
||||
|
||||
# Funciones que podrian componerse (misma firma de retorno)
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
|
||||
```
|
||||
|
||||
**Clasificar resultados en:**
|
||||
@@ -103,7 +103,7 @@ Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un ag
|
||||
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
|
||||
|
||||
```
|
||||
Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry:
|
||||
Crea la siguiente funcion para el registry fn_registry en $HOME/fn_registry:
|
||||
|
||||
Funcion: {nombre}
|
||||
Kind: {kind}
|
||||
@@ -149,7 +149,7 @@ Despues de que TODOS los fn-constructor terminen:
|
||||
|
||||
```bash
|
||||
# Indexar todo de una vez
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
||||
@@ -166,7 +166,7 @@ Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
|
||||
|
||||
```bash
|
||||
# Verificar cada funcion creada
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
./fn show {id_de_cada_funcion}
|
||||
|
||||
# Verificar que no hay funciones sin params_schema
|
||||
@@ -178,7 +178,7 @@ cd /home/lucas/fn_registry
|
||||
Para cada funcion con tests, ejecutar:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Go
|
||||
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
|
||||
@@ -197,13 +197,13 @@ bash bash/functions/{domain}/{nombre}_test.sh
|
||||
|
||||
```bash
|
||||
# Verificar que todas las funciones nuevas estan en la BD
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
|
||||
|
||||
# Verificar que los tests estan indexados
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
|
||||
|
||||
# Verificar dependencias
|
||||
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
||||
sqlite3 $HOME/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
|
||||
```
|
||||
|
||||
### 6.4 Si algo fallo
|
||||
|
||||
@@ -45,7 +45,7 @@ Antes de escribir nada, repasar la conversacion y juntar:
|
||||
|
||||
2. **Cambios concretos** desde git:
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
git status --short
|
||||
git diff --stat
|
||||
git log --since="6 hours ago" --oneline
|
||||
@@ -70,7 +70,7 @@ Si el material es solo conversacion exploratoria sin artefactos tocados, ir dire
|
||||
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
|
||||
# Funcion / tipo
|
||||
sqlite3 registry.db "SELECT id, file_path FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NAME* OR description:NAME*');"
|
||||
@@ -180,7 +180,7 @@ Para cada `.md` identificado:
|
||||
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry && ./fn index
|
||||
cd $HOME/fn_registry && ./fn index
|
||||
```
|
||||
|
||||
Y verificar:
|
||||
|
||||
@@ -17,7 +17,7 @@ Suite ya instalada en `cpp/vendor/imgui_test_engine/`. Integracion en framework:
|
||||
### 1. Resolver app y directorio
|
||||
|
||||
```bash
|
||||
ROOT=/home/lucas/fn_registry
|
||||
ROOT=$HOME/fn_registry
|
||||
ARGS="$ARGUMENTS"
|
||||
APP_ARG="${ARGS%% *}" # primera palabra
|
||||
FLOW_DESC="${ARGS#* }" # resto (puede coincidir con APP_ARG si solo hay una palabra)
|
||||
|
||||
@@ -17,7 +17,7 @@ Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja t
|
||||
|
||||
2. **Llamar la función del registry**:
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
source bash/functions/infra/append_diary_entry.sh
|
||||
append_diary_entry "<TITULO>" "$(cat <<'EOF'
|
||||
<CUERPO>
|
||||
|
||||
@@ -50,7 +50,7 @@ Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decid
|
||||
### 1. AUDIT — ¿estoy siendo registrado?
|
||||
|
||||
```bash
|
||||
ROOT="/home/lucas/fn_registry"
|
||||
ROOT="$HOME/fn_registry"
|
||||
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
|
||||
|
||||
# Pre-condiciones
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
./fn run full_git_pull_bash_pipelines
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}"
|
||||
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Wrapper sobre el pipeline `init_cpp_app_bash_pipelines`. Genera la estructura canonica que cumple `cpp/PATTERNS.md` y `.claude/rules/cpp_apps.md` (main.cpp con `cfg.about/log/panels`, sin `app_menubar` manual, dockspace via framework), registra la app en `cpp/CMakeLists.txt`, crea repo Gitea `dataforge/<name>` y ejecuta `fn index`.
|
||||
|
||||
```bash
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
./fn run init_cpp_app $ARGUMENTS
|
||||
```
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Si vacio: detectar app desde `pwd` (si estas dentro de `apps/<X>/` o `projects/*
|
||||
### 1. Resolver app objetivo
|
||||
|
||||
```bash
|
||||
ROOT=/home/lucas/fn_registry
|
||||
ROOT=$HOME/fn_registry
|
||||
ARG="$ARGUMENTS"
|
||||
|
||||
if [ -z "$ARG" ]; then
|
||||
|
||||
@@ -38,3 +38,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
```bash
|
||||
# 1. Agente trabaja en worktree del repo padre
|
||||
cd /home/lucas/fn_registry/worktrees/<slug>
|
||||
cd $HOME/fn_registry/worktrees/<slug>
|
||||
|
||||
# 2. Scaffold la app via pipeline canonico
|
||||
./fn run init_cpp_app <name> # apps C++
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# DoD Quality Triada
|
||||
|
||||
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
|
||||
|
||||
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
|
||||
|
||||
---
|
||||
|
||||
## Por que existe esta regla
|
||||
|
||||
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
|
||||
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
|
||||
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
|
||||
- El dashboard de observabilidad lleva 30 dias sin abrirse.
|
||||
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
|
||||
- El approval flow se salta porque "para test es mas comodo".
|
||||
|
||||
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
|
||||
|
||||
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
|
||||
|
||||
---
|
||||
|
||||
## Las 3 capas
|
||||
|
||||
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
|
||||
|
||||
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
|
||||
|
||||
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
|
||||
|
||||
### Capa 2: Cobertura de comportamiento
|
||||
|
||||
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
|
||||
|
||||
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
|
||||
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
|
||||
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
|
||||
|
||||
Formato canonico (tabla en `## Definition of Done` del flow/issue):
|
||||
|
||||
```markdown
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
|
||||
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
|
||||
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
|
||||
```
|
||||
|
||||
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
|
||||
|
||||
### Capa 3: Vida util validada
|
||||
|
||||
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
|
||||
|
||||
Formato canonico:
|
||||
|
||||
```markdown
|
||||
| Metrica | Umbral | Donde se observa | Ventana |
|
||||
|---|---|---|---|
|
||||
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
|
||||
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
|
||||
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
|
||||
```
|
||||
|
||||
Reglas:
|
||||
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
|
||||
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
|
||||
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
|
||||
|
||||
### Capa transversal: User-facing reforzado
|
||||
|
||||
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
|
||||
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
|
||||
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
|
||||
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
|
||||
- Latencia medida (no declarada).
|
||||
|
||||
---
|
||||
|
||||
## Reglas duras para marcar `status: done`
|
||||
|
||||
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
|
||||
|
||||
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
|
||||
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
|
||||
3. Vida util tiene tabla vacia o sin dashboard observable real.
|
||||
4. User-facing usage real <7 dias o <N usos declarados.
|
||||
5. Cualquier anti-criterio marcado como cierto.
|
||||
6. `## Notas` sin parrafo onboarding.
|
||||
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
|
||||
|
||||
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
|
||||
|
||||
---
|
||||
|
||||
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
|
||||
|
||||
| Antipatron | Por que es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
|
||||
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
|
||||
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
|
||||
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
|
||||
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
|
||||
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
|
||||
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
|
||||
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
|
||||
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
|
||||
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
|
||||
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
|
||||
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
|
||||
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
|
||||
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
|
||||
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
|
||||
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
|
||||
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
|
||||
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
|
||||
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
|
||||
@@ -3,6 +3,10 @@
|
||||
"registry": {
|
||||
"command": "./apps/registry_mcp/registry_mcp",
|
||||
"args": ["--enable-run", "--enable-write"]
|
||||
},
|
||||
"jupyter": {
|
||||
"command": "bash",
|
||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
|
||||
[2026-05-22 23:24:14.758] [INFO] [connect] OK
|
||||
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
|
||||
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
|
||||
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
|
||||
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
|
||||
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
|
||||
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
|
||||
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
|
||||
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: chrome_load_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
|
||||
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
|
||||
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
params:
|
||||
- name: "--port N"
|
||||
desc: "Puerto de remote debugging CDP. Default: 9222."
|
||||
- name: "--profile DIR"
|
||||
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
|
||||
- name: "--ext PATH"
|
||||
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
|
||||
- name: "--proxy URL"
|
||||
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
|
||||
- name: "--url URL"
|
||||
desc: "URL inicial opcional para abrir con --new-window."
|
||||
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
|
||||
file_path: "bash/functions/browser/chrome_load_extensions.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
chrome_load_extensions \
|
||||
--port 9222 \
|
||||
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
|
||||
--ext 'C:\Users\lucas\hls-dl-ext' \
|
||||
--ext 'C:\Users\lucas\ubol' \
|
||||
--proxy http://127.0.0.1:8889 \
|
||||
--url https://www.gnularetro.cc/
|
||||
```
|
||||
|
||||
Sin proxy ni URL, sólo extensiones:
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/chrome_load_extensions.sh
|
||||
|
||||
pid=$(chrome_load_extensions \
|
||||
--ext '/home/lucas/dev/hls-dl-ext' \
|
||||
--ext '/home/lucas/dev/ubol')
|
||||
# Paths WSL traducidos automáticamente a Windows.
|
||||
# CDP listo en 127.0.0.1:9222.
|
||||
echo "Chrome PID: $pid"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
|
||||
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
|
||||
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
|
||||
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
|
||||
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
|
||||
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
|
||||
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
|
||||
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
|
||||
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
|
||||
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
|
||||
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
|
||||
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
|
||||
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
|
||||
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
|
||||
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
|
||||
|
||||
chrome_load_extensions() {
|
||||
local port=9222
|
||||
local profile=""
|
||||
local proxy=""
|
||||
local url=""
|
||||
local -a ext_paths=()
|
||||
|
||||
# --- Parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"; shift 2 ;;
|
||||
--profile)
|
||||
profile="$2"; shift 2 ;;
|
||||
--ext)
|
||||
ext_paths+=("$2"); shift 2 ;;
|
||||
--proxy)
|
||||
proxy="$2"; shift 2 ;;
|
||||
--url)
|
||||
url="$2"; shift 2 ;;
|
||||
--*)
|
||||
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
|
||||
*)
|
||||
# Positional = extra ext path
|
||||
ext_paths+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#ext_paths[@]} -eq 0 ]]; then
|
||||
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar chrome.exe ---
|
||||
local chrome_bin=""
|
||||
if command -v chrome.exe &>/dev/null; then
|
||||
chrome_bin="chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
|
||||
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
|
||||
else
|
||||
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Detectar WSL2 ---
|
||||
local wsl2=0
|
||||
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
|
||||
wsl2=1
|
||||
fi
|
||||
|
||||
# --- Traducir paths de extensiones a Windows si hace falta ---
|
||||
local -a win_ext_paths=()
|
||||
for p in "${ext_paths[@]}"; do
|
||||
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux → traducir a Windows
|
||||
local win_p
|
||||
win_p=$(wslpath -w "$p" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
|
||||
return 1
|
||||
}
|
||||
win_ext_paths+=("$win_p")
|
||||
else
|
||||
win_ext_paths+=("$p")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Resolver perfil ---
|
||||
if [[ -z "$profile" ]]; then
|
||||
# Default: perfil canónico fn-chrome-cdp-profile en Windows
|
||||
local win_user="${USERNAME:-${USER:-lucas}}"
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
|
||||
else
|
||||
profile="/tmp/fn-chrome-cdp-profile"
|
||||
fi
|
||||
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
|
||||
# Path Linux del perfil → traducir a Windows
|
||||
profile=$(wslpath -w "$profile" 2>/dev/null) || {
|
||||
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Construir lista de paths separada por coma (para Chrome) ---
|
||||
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
|
||||
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
|
||||
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
|
||||
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
|
||||
local ext_list=""
|
||||
local p
|
||||
for p in "${win_ext_paths[@]}"; do
|
||||
ext_list+="${ext_list:+,}${p}"
|
||||
done
|
||||
|
||||
# --- Construir args de Chrome ---
|
||||
local -a args=(
|
||||
"--remote-debugging-port=${port}"
|
||||
"--user-data-dir=${profile}"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
"--remote-allow-origins=*"
|
||||
"--load-extension=${ext_list}"
|
||||
"--disable-extensions-except=${ext_list}"
|
||||
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
|
||||
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
|
||||
# extensiones unpacked no cargan (chrome://extensions sale vacio).
|
||||
"--disable-features=DisableLoadExtensionCommandLineSwitch"
|
||||
)
|
||||
|
||||
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
|
||||
if [[ $wsl2 -eq 1 ]]; then
|
||||
args+=("--remote-debugging-address=0.0.0.0")
|
||||
fi
|
||||
|
||||
if [[ -n "$proxy" ]]; then
|
||||
args+=("--proxy-server=${proxy}")
|
||||
fi
|
||||
|
||||
if [[ -n "$url" ]]; then
|
||||
args+=("--new-window" "$url")
|
||||
fi
|
||||
|
||||
# --- Revisar si CDP ya responde en el puerto ---
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
|
||||
fi
|
||||
|
||||
# --- Lanzar Chrome desacoplado del proceso padre ---
|
||||
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
|
||||
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
|
||||
local chrome_pid=$!
|
||||
disown "$chrome_pid"
|
||||
|
||||
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
|
||||
|
||||
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
|
||||
local deadline=$(( $(date +%s) + 15 ))
|
||||
local ready=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [[ $ready -eq 1 ]]; then
|
||||
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
|
||||
else
|
||||
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
|
||||
fi
|
||||
|
||||
echo "$chrome_pid"
|
||||
}
|
||||
@@ -38,7 +38,7 @@ if [[ -n "$matches" ]]; then
|
||||
fi
|
||||
|
||||
# Escanear repo especifico
|
||||
scan_secrets_in_dirty /home/lucas/fn_registry
|
||||
scan_secrets_in_dirty $HOME/fn_registry
|
||||
```
|
||||
|
||||
## Patrones detectados
|
||||
|
||||
@@ -22,14 +22,14 @@ 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."
|
||||
desc: "Ruta absoluta al directorio fuente de la app (ej: $HOME/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"
|
||||
deploy_cpp_exe_to_windows "chart_demo" "$HOME/fn_registry/cpp/apps/chart_demo"
|
||||
# OK: chart_demo -> /mnt/c/Users/lucas/Desktop/apps/chart_demo
|
||||
|
||||
# Con rutas custom via env vars
|
||||
@@ -55,7 +55,7 @@ Desktop/apps/<APP>/
|
||||
|
||||
- `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`
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
|
||||
|
||||
## Notas
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ deploy_cpp_exe_to_windows() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
local root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
|
||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: deploy_wails_exe_to_windows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "deploy_wails_exe_to_windows <app_name> <app_dir>"
|
||||
description: "Copia el .exe de una app Wails desde <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso anterior (taskkill /F) y relanza la app via cmd.exe. Single-binary: no copia DLLs (Webview2 nativo en SO). Preserva local_files/ si existe."
|
||||
tags: ["wails", "windows", "deploy", "cross-compile", "mingw", "infra", "launch", "matrix-mas"]
|
||||
params:
|
||||
- name: app_name
|
||||
desc: "Nombre del binario sin extension (ej. matrix_client_pc). Debe coincidir con el nombre del .exe generado por wails build."
|
||||
- name: app_dir
|
||||
desc: "Ruta absoluta al directorio raiz de la app, donde vive build/bin/<app>.exe. Puede estar en projects/<project>/apps/<app>/ o apps/<app>/."
|
||||
output: "Imprime pasos en stderr. En stdout: ls -lh del .exe desplegado. Exit 0 si ok, exit 1 si build/bin/<app>.exe no existe o los args estan vacios."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "args vacios devuelven error con mensaje de uso"
|
||||
- "app_dir inexistente devuelve exit 1"
|
||||
- "build/bin exe inexistente devuelve exit 1"
|
||||
test_file_path: "bash/functions/infra/deploy_wails_exe_to_windows_test.sh"
|
||||
file_path: "bash/functions/infra/deploy_wails_exe_to_windows.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/deploy_wails_exe_to_windows.sh
|
||||
|
||||
# Desplegar matrix_client_pc tras wails build -platform windows/amd64
|
||||
deploy_wails_exe_to_windows matrix_client_pc \
|
||||
$HOME/fn_registry/projects/element_agents/apps/matrix_client_pc
|
||||
```
|
||||
|
||||
Con override de destino:
|
||||
|
||||
```bash
|
||||
WIN_DESKTOP_APPS=/mnt/c/Users/lucas/Desktop/apps \
|
||||
deploy_wails_exe_to_windows matrix_admin_panel \
|
||||
$HOME/fn_registry/projects/element_agents/apps/matrix_admin_panel
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras un `wails build -platform windows/amd64` exitoso, para desplegar el binario compilado en Windows y relanzarlo en el mismo paso. Ideal en el ciclo de iteracion rapida: compilar → desplegar → ver cambios. Equivalente a `deploy_cpp_exe_to_windows_bash_infra` pero para apps Wails (single-binary sin DLLs extras).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **taskkill /F fuerza muerte** sin permitir guardado en disco. Las apps Wails persisten estado en keyring de Windows y AppData — este kill es seguro para ellas. Si la app tuviera autosave en progreso, se perderia (aceptable en ciclos de dev).
|
||||
- **UNC paths prohibidos en cmd.exe**: `cmd.exe /c start` debe ejecutarse con `cd` previo al directorio Windows (`/mnt/c/...`). Intentar lanzar con path `\\wsl.localhost\...` falla con "UNC paths are not supported as the current directory".
|
||||
- **cmd.exe start no bloquea**: la funcion espera 3s y verifica via `tasklist.exe`. Si la app cierra sola tras el arranque (error de inicio), el warn final lo indica pero no causa exit 1. Revisar logs en `%APPDATA%\<app>\` o `%LOCALAPPDATA%\<app>\`.
|
||||
- **Single-binary Wails**: no copiar DLLs. Webview2 es nativo del SO (Windows 10+ ya lo incluye). Si una version vieja de Windows no tuviera Webview2, la app falla al arrancar — solucion: instalar Webview2 Runtime en esa maquina.
|
||||
- **Build previo es responsabilidad del caller**: esta funcion NO compila. Para matrix_client_pc usa `-tags goolm` por el crypto de Matrix: `wails build -platform windows/amd64 -tags goolm`.
|
||||
- **WIN_DESKTOP_APPS override**: variable de entorno para cambiar el destino. Util en CI o maquinas con escritorio en otra ruta.
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy_wails_exe_to_windows — Copia el .exe de una app Wails compilado en
|
||||
# <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso
|
||||
# anterior y relanza la app. Single-binary: no copia DLLs (Webview2 nativo SO).
|
||||
# Pre-authorized: taskkill.exe /F — idempotente, sin prompt.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
deploy_wails_exe_to_windows() {
|
||||
local app="${1:-}"
|
||||
local app_dir="${2:-}"
|
||||
|
||||
if [ -z "$app" ] || [ -z "$app_dir" ]; then
|
||||
echo "ERROR: uso: deploy_wails_exe_to_windows <app_name> <app_dir>" >&2
|
||||
echo " app_name: nombre del binario sin extension (ej. matrix_client_pc)" >&2
|
||||
echo " app_dir: ruta absoluta al directorio de la app (donde vive build/bin/)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
|
||||
|
||||
# --- 1. Validar que el .exe existe ---
|
||||
local exe_src="${app_dir}/build/bin/${app}.exe"
|
||||
if [ ! -f "$exe_src" ]; then
|
||||
echo "ERROR: no se encontro $exe_src" >&2
|
||||
echo "Compila primero con: wails build -platform windows/amd64" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- 2. Crear directorio destino (preserva local_files/ si existe) ---
|
||||
local dest="${win_desktop_apps}/${app}"
|
||||
mkdir -p "$dest"
|
||||
echo "[deploy_wails] dest: $dest" >&2
|
||||
|
||||
# --- 3. Matar proceso si esta corriendo en Windows ---
|
||||
# Pre-authorized. Wails apps usan AppData+keyring para estado, kill /F es seguro.
|
||||
if command -v taskkill.exe >/dev/null 2>&1; then
|
||||
echo "[deploy_wails] matando ${app}.exe si corre..." >&2
|
||||
taskkill.exe /IM "${app}.exe" /F 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- 4. Esperar a que Windows libere el file handle ---
|
||||
sleep 1
|
||||
|
||||
# --- 5. Copiar .exe (cp -f: overwrite sin borrar el directorio) ---
|
||||
echo "[deploy_wails] copiando ${app}.exe..." >&2
|
||||
cp -f "$exe_src" "$dest/${app}.exe"
|
||||
|
||||
# --- 6. Copiar appicon.ico si existe (opcional, algunos hubs lo leen) ---
|
||||
local icon_src="${app_dir}/appicon.ico"
|
||||
if [ -f "$icon_src" ]; then
|
||||
echo "[deploy_wails] copiando appicon.ico..." >&2
|
||||
cp -f "$icon_src" "$dest/appicon.ico"
|
||||
fi
|
||||
|
||||
# --- 7. Relanzar la app desde su dir Windows ---
|
||||
# Usar cmd.exe /c start desde el dir destino (no UNC paths — falla en cmd.exe).
|
||||
echo "[deploy_wails] lanzando ${app}.exe..." >&2
|
||||
(
|
||||
cd "$dest"
|
||||
cmd.exe /c start "" "${app}.exe"
|
||||
)
|
||||
|
||||
# --- 8. Dar tiempo a que el proceso arranque ---
|
||||
sleep 3
|
||||
|
||||
# --- 9. Verificar que el proceso esta corriendo ---
|
||||
if command -v tasklist.exe >/dev/null 2>&1; then
|
||||
local tasklist_out
|
||||
tasklist_out=$(tasklist.exe /FI "IMAGENAME eq ${app}.exe" /NH 2>/dev/null || true)
|
||||
if echo "$tasklist_out" | grep -qi "^${app}.exe"; then
|
||||
local pid
|
||||
pid=$(echo "$tasklist_out" | grep -i "^${app}.exe" | awk '{print $2}' | head -n1)
|
||||
echo "[deploy_wails] ${app}.exe corriendo con PID $pid" >&2
|
||||
else
|
||||
echo "WARN: ${app}.exe no aparece en tasklist tras el lanzamiento." >&2
|
||||
echo " Puede que la app cerro con error. Revisar AppData para logs." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 10. Resumen final en stdout ---
|
||||
ls -lh "$dest/${app}.exe"
|
||||
|
||||
echo "[deploy_wails] OK: ${app} deployado en $dest" >&2
|
||||
if [ -d "$dest/local_files" ]; then
|
||||
echo "[deploy_wails] local_files/ preservado: $(du -sh "$dest/local_files" | cut -f1)" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
deploy_wails_exe_to_windows "$@"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para deploy_wails_exe_to_windows
|
||||
# Solo prueba validacion de argumentos y rutas — no ejecuta taskkill/cmd.exe reales.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/deploy_wails_exe_to_windows.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local test_name="$1" expected="$2" got="$3"
|
||||
if [[ "$expected" == "$got" ]]; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected '$expected', got '$got'"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Test 1: args vacios devuelven error con mensaje de uso ---
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows >/dev/null 2>&1 || actual_exit=$?
|
||||
assert_eq "args vacios devuelven error con mensaje de uso" "1" "$actual_exit"
|
||||
|
||||
# --- Test 2: app_dir inexistente devuelve exit 1 ---
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows "myapp" "/tmp/nonexistent_dir_$(date +%s)" >/dev/null 2>&1 || actual_exit=$?
|
||||
assert_eq "app_dir inexistente devuelve exit 1" "1" "$actual_exit"
|
||||
|
||||
# --- Test 3: build/bin exe inexistente devuelve exit 1 ---
|
||||
TMPDIR_APP=$(mktemp -d)
|
||||
# Crear estructura de dir de app pero SIN el exe
|
||||
mkdir -p "$TMPDIR_APP/build/bin"
|
||||
actual_exit=0
|
||||
deploy_wails_exe_to_windows "myapp" "$TMPDIR_APP" >/dev/null 2>&1 || actual_exit=$?
|
||||
rm -rf "$TMPDIR_APP"
|
||||
assert_eq "build/bin exe inexistente devuelve exit 1" "1" "$actual_exit"
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -30,15 +30,15 @@ file_path: "bash/functions/infra/discover_git_repos.sh"
|
||||
source bash/functions/infra/discover_git_repos.sh
|
||||
|
||||
# Listar todos los repos bajo fn_registry
|
||||
discover_git_repos /home/lucas/fn_registry
|
||||
discover_git_repos $HOME/fn_registry
|
||||
|
||||
# Contar repos
|
||||
discover_git_repos /home/lucas/fn_registry | wc -l
|
||||
discover_git_repos $HOME/fn_registry | wc -l
|
||||
|
||||
# Iterar
|
||||
while IFS= read -r repo; do
|
||||
echo "Repo: $repo"
|
||||
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||
done < <(discover_git_repos $HOME/fn_registry)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
@@ -33,7 +33,7 @@ file_path: "bash/functions/infra/docker_cp_file.sh"
|
||||
```bash
|
||||
source functions/infra/docker_cp_file.sh
|
||||
|
||||
result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
|
||||
result=$(docker_cp_file $HOME/fn_registry/registry.db metabase /registry.db)
|
||||
echo "$result"
|
||||
# {"local_size":524288,"remote_size":524288}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ file_path: "bash/functions/infra/git_auto_commit_dirty.sh"
|
||||
source bash/functions/infra/git_auto_commit_dirty.sh
|
||||
|
||||
# Commitear con mensaje automatico
|
||||
subject=$(git_auto_commit_dirty /home/lucas/fn_registry)
|
||||
subject=$(git_auto_commit_dirty $HOME/fn_registry)
|
||||
echo "Commit: $subject"
|
||||
|
||||
# Commitear con mensaje fijo
|
||||
|
||||
@@ -17,7 +17,7 @@ error_type: "error_go_core"
|
||||
imports: []
|
||||
example: |
|
||||
# Manual check
|
||||
bash bash/functions/infra/git_hook_audit_app_drift.sh /home/lucas/fn_registry/apps/kanban
|
||||
bash bash/functions/infra/git_hook_audit_app_drift.sh $HOME/fn_registry/apps/kanban
|
||||
|
||||
# Used by pre_commit_hook_install_bash_infra (v2 hook chain)
|
||||
file_path: bash/functions/infra/git_hook_audit_app_drift.sh
|
||||
|
||||
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_pull_with_stash.sh"
|
||||
source bash/functions/infra/git_pull_with_stash.sh
|
||||
|
||||
# Pullear repo con auto-stash
|
||||
status=$(git_pull_with_stash /home/lucas/fn_registry)
|
||||
status=$(git_pull_with_stash $HOME/fn_registry)
|
||||
echo "$status"
|
||||
# [pulled] fn_registry
|
||||
# o:
|
||||
@@ -46,7 +46,7 @@ while IFS= read -r repo; do
|
||||
if [[ "$result" == "[diverged]"* || "$result" == "[stash-conflict]"* ]]; then
|
||||
diverged+=("$result")
|
||||
fi
|
||||
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||
done < <(discover_git_repos $HOME/fn_registry)
|
||||
|
||||
if [[ ${#diverged[@]} -gt 0 ]]; then
|
||||
echo "ATENCION: repos que requieren intervencion manual:"
|
||||
|
||||
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_push_if_ahead.sh"
|
||||
source bash/functions/infra/git_push_if_ahead.sh
|
||||
|
||||
# Pushear si hay commits locales
|
||||
status=$(git_push_if_ahead /home/lucas/fn_registry)
|
||||
status=$(git_push_if_ahead $HOME/fn_registry)
|
||||
echo "$status"
|
||||
# [push] fn_registry (master, 3 commits ahead)
|
||||
# o:
|
||||
@@ -39,7 +39,7 @@ echo "$status"
|
||||
# Iterar sobre multiples repos
|
||||
while IFS= read -r repo; do
|
||||
git_push_if_ahead "$repo"
|
||||
done < <(discover_git_repos /home/lucas/fn_registry)
|
||||
done < <(discover_git_repos $HOME/fn_registry)
|
||||
```
|
||||
|
||||
## Estados de salida
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: jupyter_mcp_serve
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
error_type: "error_go_core"
|
||||
signature: "jupyter_mcp_serve.sh [--dry-run]"
|
||||
description: "Arranca (o reusa) un Jupyter Lab colaborativo en un puerto propio y lanza el Jupyter MCP server enganchado por stdio. Entrypoint robusto para la entrada 'jupyter' de .mcp.json: garantiza que el MCP SIEMPRE tiene servidor al que conectarse, sin depender de que haya un jupyter en 8888."
|
||||
tags: [notebook, jupyter, mcp, infra, launcher-glue]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
params:
|
||||
- name: "--dry-run"
|
||||
desc: "Opcional. Arranca/verifica jupyter pero NO hace exec del MCP; loguea el comando elegido. Para tests."
|
||||
output: "Proceso jupyter-mcp-server enganchado por stdio a un Jupyter Lab colaborativo local (127.0.0.1, puerto JUPYTER_MCP_PORT, default 8899). Logs en ~/.fn_jupyter_mcp/. stdout reservado al protocolo MCP."
|
||||
---
|
||||
|
||||
## Que hace
|
||||
|
||||
El MCP de Jupyter (datalayer `jupyter-mcp-server`) **no arranca jupyter**, solo se
|
||||
conecta a uno existente. Si la URL configurada no tiene jupyter detras, el MCP
|
||||
nunca conecta. En esta maquina `localhost:8888` es el **proxy HTTP del contenedor
|
||||
VPN gluetun**, no un jupyter — por eso el MCP fallaba siempre.
|
||||
|
||||
Este wrapper resuelve la cadena entera:
|
||||
|
||||
1. Localiza el venv (`python/.venv`) y los binarios `jupyter` + `jupyter-mcp-server`.
|
||||
2. Si ya hay un jupyter gestionado vivo en `127.0.0.1:$PORT` (`/api/status` = 200) lo reusa.
|
||||
3. Si no, arranca `jupyter lab` colaborativo detached (RTC via `jupyter-collaboration`),
|
||||
en `JUPYTER_MCP_ROOT` (default = raiz del repo, asi cualquier notebook del arbol es lanzable).
|
||||
4. Detecta el dialecto de CLI del MCP (`--document-url` nuevo / `--jupyter-url` viejo / env vars)
|
||||
y hace `exec` del MCP por `--transport stdio`.
|
||||
|
||||
Self-adapting: funciona aunque cambie la version de `jupyter-mcp-server`.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Como lo usa Claude Code (entrada en .mcp.json):
|
||||
# "jupyter": { "command": "bash", "args": ["bash/functions/infra/jupyter_mcp_serve.sh"] }
|
||||
|
||||
# Test manual (arranca jupyter en 8899, no lanza el MCP):
|
||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
||||
curl -s http://127.0.0.1:8899/api/status # {"started":..., "version":...}
|
||||
|
||||
# Cambiar puerto / raiz de notebooks:
|
||||
JUPYTER_MCP_PORT=8900 JUPYTER_MCP_ROOT=/home/enmanuel/fn_registry/analysis \
|
||||
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras que el MCP de Jupyter de Claude Code **siempre** tenga servidor:
|
||||
es el `command` de la entrada `jupyter` en `.mcp.json`. No la invoques a mano salvo
|
||||
para depurar (`--dry-run`) o para levantar el jupyter colaborativo sin el MCP.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **stdout reservado**: el MCP habla por stdout (protocolo stdio). El wrapper jamas
|
||||
escribe a stdout — todo log va a stderr y `~/.fn_jupyter_mcp/wrapper.log`. No metas
|
||||
`echo` a stdout aqui o rompes el handshake del MCP.
|
||||
- **Puerto 8888 ocupado por gluetun** en esta maquina. Por eso el default es **8899**.
|
||||
Si 8899 tambien se ocupa, exporta `JUPYTER_MCP_PORT`.
|
||||
- **Token vacio**: solo escucha en `127.0.0.1` con `disable_check_xsrf` + `allow_origin '*'`.
|
||||
Aceptable en local; NO exponer el puerto a la red.
|
||||
- **venv requerido**: necesita `python/.venv` con `jupyterlab`, `jupyter-collaboration`
|
||||
y `jupyter-mcp-server`. Reconstruir: `cd python && uv sync --extra jupyter`.
|
||||
- El jupyter arrancado queda **detached** (nohup): persiste entre invocaciones del MCP.
|
||||
Para pararlo: `python/.venv/bin/jupyter server stop 8899` o `pkill -f 'jupyter-lab.*8899'`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 (2026-06-01) — version inicial. Wrapper auto-start: reusa/levanta jupyter
|
||||
colaborativo en puerto propio (8899) y autodetecta el dialecto de CLI del MCP.
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# jupyter_mcp_serve — arranca (o reusa) un Jupyter Lab colaborativo y lanza el
|
||||
# Jupyter MCP server enganchado a el por stdio. Pensado para ser el `command` de
|
||||
# la entrada "jupyter" en .mcp.json: garantiza que el MCP SIEMPRE tiene servidor.
|
||||
#
|
||||
# Por que existe: el MCP datalayer NO arranca jupyter, solo se conecta. Si la URL
|
||||
# apunta a un puerto sin jupyter (en esta maquina 8888 = proxy VPN gluetun), el
|
||||
# MCP nunca conecta. Este wrapper levanta su propio jupyter en un puerto propio.
|
||||
#
|
||||
# Env overrides:
|
||||
# JUPYTER_MCP_ROOT raiz de notebooks (default: raiz del repo)
|
||||
# JUPYTER_MCP_PORT puerto del jupyter gestionado (default: 8899)
|
||||
# JUPYTER_MCP_VENV venv (default: <repo>/python/.venv)
|
||||
# JUPYTER_MCP_TOKEN token (default: "" — solo escucha en 127.0.0.1)
|
||||
#
|
||||
# stdout esta RESERVADO al protocolo stdio del MCP. Todo log va a stderr + LOGFILE.
|
||||
# Nunca hacer echo a stdout aqui.
|
||||
#
|
||||
# Uso directo / test:
|
||||
# bash jupyter_mcp_serve.sh --dry-run # arranca jupyter, NO exec del MCP, loguea args
|
||||
set -euo pipefail
|
||||
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# raiz del repo = tres niveles arriba de bash/functions/infra/
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
VENV="${JUPYTER_MCP_VENV:-$REPO_ROOT/python/.venv}"
|
||||
ROOT_DIR="${JUPYTER_MCP_ROOT:-$REPO_ROOT}"
|
||||
PORT="${JUPYTER_MCP_PORT:-8899}"
|
||||
HOST=127.0.0.1
|
||||
TOKEN="${JUPYTER_MCP_TOKEN:-}"
|
||||
LOGDIR="${HOME}/.fn_jupyter_mcp"
|
||||
mkdir -p "$LOGDIR"
|
||||
LOGFILE="$LOGDIR/wrapper.log"
|
||||
JLOG="$LOGDIR/jupyterlab.log"
|
||||
|
||||
log(){ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; printf '%s\n' "$*" >&2; }
|
||||
|
||||
JUPYTER="$VENV/bin/jupyter"
|
||||
MCP="$VENV/bin/jupyter-mcp-server"
|
||||
|
||||
if [ ! -x "$JUPYTER" ]; then
|
||||
log "FATAL: $JUPYTER no existe. Instala: cd $REPO_ROOT/python && uv pip install --python .venv/bin/python3 jupyterlab jupyter-collaboration jupyter-mcp-server"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$MCP" ]; then
|
||||
log "FATAL: $MCP no existe. Instala jupyter-mcp-server en el venv."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server_up(){
|
||||
local code
|
||||
code="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/status?token=$TOKEN" 2>/dev/null || true)"
|
||||
[ "$code" = "200" ]
|
||||
}
|
||||
|
||||
if server_up; then
|
||||
log "reuso jupyter existente en $HOST:$PORT"
|
||||
else
|
||||
log "arranco jupyter colaborativo en $HOST:$PORT (root=$ROOT_DIR)"
|
||||
nohup "$JUPYTER" lab \
|
||||
--no-browser \
|
||||
--ServerApp.ip="$HOST" \
|
||||
--ServerApp.port="$PORT" \
|
||||
--ServerApp.root_dir="$ROOT_DIR" \
|
||||
--IdentityProvider.token="$TOKEN" \
|
||||
--ServerApp.disable_check_xsrf=True \
|
||||
--ServerApp.allow_origin='*' \
|
||||
>>"$JLOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
# esperar hasta ~30s a que levante
|
||||
for _ in $(seq 1 60); do
|
||||
server_up && break
|
||||
sleep 0.5
|
||||
done
|
||||
if ! server_up; then
|
||||
log "FATAL: jupyter no levanto en 30s. Ver $JLOG"
|
||||
exit 1
|
||||
fi
|
||||
log "jupyter arriba"
|
||||
fi
|
||||
|
||||
BASE="http://$HOST:$PORT"
|
||||
|
||||
# Detectar el dialecto de CLI del MCP (cambia entre versiones de jupyter-mcp-server)
|
||||
HELP="$("$MCP" --help 2>&1 || true)"
|
||||
ARGS=(--transport stdio)
|
||||
if printf '%s' "$HELP" | grep -q -- '--document-url'; then
|
||||
ARGS+=(--document-url "$BASE" --runtime-url "$BASE")
|
||||
printf '%s' "$HELP" | grep -q -- '--document-token' && ARGS+=(--document-token "$TOKEN" --runtime-token "$TOKEN")
|
||||
elif printf '%s' "$HELP" | grep -q -- '--jupyter-url'; then
|
||||
ARGS+=(--jupyter-url "$BASE" --jupyter-token "$TOKEN")
|
||||
else
|
||||
# fallback: variables de entorno que las distintas versiones reconocen
|
||||
export DOCUMENT_URL="$BASE" RUNTIME_URL="$BASE" DOCUMENT_TOKEN="$TOKEN" RUNTIME_TOKEN="$TOKEN"
|
||||
export JUPYTER_URL="$BASE" JUPYTER_TOKEN="$TOKEN"
|
||||
fi
|
||||
|
||||
log "MCP cmd: $MCP ${ARGS[*]}"
|
||||
|
||||
if [ "$DRY" = "1" ]; then
|
||||
log "--dry-run: no ejecuto el MCP. Jupyter sigue corriendo en $BASE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "$MCP" "${ARGS[@]}"
|
||||
@@ -61,7 +61,7 @@ Mitad complementaria de `deploy_cpp_exe_to_windows_bash_infra`. El flujo complet
|
||||
build_cpp_windows "registry_dashboard"
|
||||
|
||||
# 2. Copiar al escritorio (mata proceso si corre, copia DLLs+assets)
|
||||
deploy_cpp_exe_to_windows "registry_dashboard" "/home/lucas/fn_registry/apps/registry_dashboard"
|
||||
deploy_cpp_exe_to_windows "registry_dashboard" "$HOME/fn_registry/apps/registry_dashboard"
|
||||
|
||||
# 3. Lanzar
|
||||
launch_cpp_app_windows "registry_dashboard"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: mas_client_register
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json"
|
||||
description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios."
|
||||
tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: ssh_host
|
||||
desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth."
|
||||
- name: container
|
||||
desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml."
|
||||
- name: config_file
|
||||
desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml."
|
||||
- name: dry_run
|
||||
desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861."
|
||||
output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)."
|
||||
tested: true
|
||||
tests:
|
||||
- "help flag emite JSON parseable"
|
||||
- "args faltantes retornan JSON de error sin ssh"
|
||||
- "jq disponible en host local"
|
||||
test_file_path: "bash/functions/infra/mas_client_register_test.sh"
|
||||
file_path: "bash/functions/infra/mas_client_register.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dry-run: verificar que clients se aplicarian correctamente
|
||||
source bash/functions/infra/mas_client_register.sh
|
||||
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \
|
||||
--dry-run
|
||||
|
||||
# Aplicar sync real (con --prune para eliminar clients viejos)
|
||||
mas_client_register \
|
||||
--ssh-host organic-machine.com \
|
||||
--container element_matrix_chat-mas-1 \
|
||||
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml
|
||||
```
|
||||
|
||||
Salida esperada (sync OK):
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"applied": true,
|
||||
"clients_total": 6,
|
||||
"clients_diff": ["synced client element-web", "synced client synapse-admin", "..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
Salida dry-run:
|
||||
```json
|
||||
{
|
||||
"status": "dry-run",
|
||||
"applied": false,
|
||||
"clients_total": 42,
|
||||
"clients_diff": ["clients:", " - client_id: element-web", " ..."],
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion.
|
||||
- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS.
|
||||
- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec <container> mas-cli --version`.
|
||||
- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script.
|
||||
- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh <alias>` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes.
|
||||
- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores.
|
||||
- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide).
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS)
|
||||
# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH.
|
||||
set -euo pipefail
|
||||
|
||||
mas_client_register() {
|
||||
local ssh_host=""
|
||||
local container=""
|
||||
local config_file=""
|
||||
local dry_run=false
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--container)
|
||||
container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--config-file)
|
||||
config_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync
|
||||
|
||||
Usage:
|
||||
mas_client_register --ssh-host <host> --container <name> --config-file <path> [--dry-run]
|
||||
|
||||
Options:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml)
|
||||
--dry-run Solo valida config y muestra diff, sin aplicar cambios
|
||||
|
||||
Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr
|
||||
USAGE
|
||||
# emit minimal valid JSON so callers that parse stdout don't break
|
||||
echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_client_register: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validar argumentos obligatorios
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$container" ]] && errors+=("--container es obligatorio")
|
||||
[[ -z "$config_file" ]] && errors+=("--config-file es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar dependencias locales
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2
|
||||
|
||||
# La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS)
|
||||
local container_config="/data/config.yaml"
|
||||
|
||||
# ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ----
|
||||
echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2
|
||||
local check_stdout check_stderr check_exit
|
||||
check_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true)
|
||||
check_exit=$?
|
||||
check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_check_stderr_$$
|
||||
|
||||
if [[ $check_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config check falló (exit=$check_exit)" >&2
|
||||
echo "$check_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_client_register: config check OK" >&2
|
||||
|
||||
# ---- PASO 2: dry-run o sync ----
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
# Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria
|
||||
echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2
|
||||
local dump_stdout dump_stderr dump_exit
|
||||
dump_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true)
|
||||
dump_exit=$?
|
||||
dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_dump_stderr_$$
|
||||
|
||||
if [[ $dump_exit -ne 0 ]]; then
|
||||
echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2
|
||||
echo "$dump_stderr" >&2
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extraer listado de clients del dump (buscar lineas con client_id o type: client)
|
||||
local clients_diff_raw
|
||||
clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \
|
||||
sed 's/^[[:space:]]*//' | head -50 || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \
|
||||
|| echo '["(jq parse error — ver stderr)"]')
|
||||
|
||||
local escaped_dump_stderr
|
||||
escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson stderr_str "$escaped_dump_stderr" \
|
||||
'{
|
||||
status: "dry-run",
|
||||
applied: false,
|
||||
clients_total: ($diff | length),
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ---- PASO 3: sync real ----
|
||||
echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2
|
||||
local sync_stdout sync_stderr sync_exit
|
||||
sync_stdout=$(ssh "$ssh_host" \
|
||||
"docker exec ${container} mas-cli config sync --config ${container_config} --prune" \
|
||||
2>/tmp/mas_sync_stderr_$$ || true)
|
||||
sync_exit=$?
|
||||
sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_sync_stderr_$$
|
||||
|
||||
echo "mas_client_register: sync exit=$sync_exit" >&2
|
||||
if [[ -n "$sync_stderr" ]]; then
|
||||
echo "mas_client_register stderr: $sync_stderr" >&2
|
||||
fi
|
||||
|
||||
if [[ $sync_exit -ne 0 ]]; then
|
||||
local escaped_stderr
|
||||
escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parsear output del sync para extraer lineas con cambios aplicados
|
||||
local diff_lines
|
||||
diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true)
|
||||
|
||||
local diff_json
|
||||
diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \
|
||||
|| echo '[]')
|
||||
|
||||
local clients_count
|
||||
clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0)
|
||||
|
||||
local escaped_sync_stderr
|
||||
escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
|
||||
|
||||
echo "mas_client_register: sync completado con exito" >&2
|
||||
|
||||
jq -n \
|
||||
--argjson diff "$diff_json" \
|
||||
--argjson total "$clients_count" \
|
||||
--argjson stderr_str "$escaped_sync_stderr" \
|
||||
'{
|
||||
status: "ok",
|
||||
applied: true,
|
||||
clients_total: $total,
|
||||
clients_diff: $diff,
|
||||
stderr: $stderr_str
|
||||
}'
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_client_register "$@"
|
||||
fi
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_client_register
|
||||
# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle', got: $haystack"
|
||||
((FAIL++))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_parseable() {
|
||||
local test_name="$1" json="$2"
|
||||
if command -v jq &>/dev/null; then
|
||||
if echo "$json" | jq . >/dev/null 2>&1; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no es JSON valido: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
else
|
||||
if [[ "$json" == \{* ]]; then
|
||||
echo "PASS: $test_name (jq no disponible, verificacion basica OK)"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: $test_name — output no parece JSON: $json"
|
||||
((FAIL++))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: help flag emite JSON parseable
|
||||
# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true
|
||||
output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_help_$$
|
||||
assert_json_parseable "help flag emite JSON parseable" "$output_help"
|
||||
|
||||
# Test: args faltantes retornan JSON de error sin ssh
|
||||
bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true
|
||||
output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true)
|
||||
rm -f /tmp/mas_test_noargs_$$
|
||||
assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs"
|
||||
assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs"
|
||||
|
||||
# Test: jq disponible en host local
|
||||
if command -v jq &>/dev/null; then
|
||||
echo "PASS: jq disponible en host local"
|
||||
((PASS++))
|
||||
else
|
||||
echo "FAIL: jq disponible en host local — instalar: apt install jq"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: mas_syn2mas_migration
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "mas_syn2mas_migration --ssh-host <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--max-conflicts N] [--apply]"
|
||||
description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply."
|
||||
tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas]
|
||||
params:
|
||||
- name: ssh-host
|
||||
desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)"
|
||||
- name: mas-container
|
||||
desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)"
|
||||
- name: synapse-config-path
|
||||
desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount."
|
||||
- name: log-dir
|
||||
desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)."
|
||||
- name: max-conflicts
|
||||
desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)."
|
||||
- name: apply
|
||||
desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold."
|
||||
output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "aborta con error cuando faltan args obligatorios"
|
||||
- "help no devuelve error"
|
||||
- "argumento desconocido retorna exit 1"
|
||||
- "max-conflicts invalido retorna exit 1"
|
||||
test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh"
|
||||
file_path: "bash/functions/infra/mas_syn2mas_migration.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada)
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0
|
||||
|
||||
# Salida esperada (si hay 0 conflicts):
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}
|
||||
|
||||
# Revisar el log antes de continuar:
|
||||
# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log
|
||||
|
||||
# Paso 2: tras revisar el log dry-run, aplicar la migracion real
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host organic-machine.com \
|
||||
--mas-container element_matrix_chat-mas-1 \
|
||||
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
|
||||
--log-dir ~/matrix_migration_logs \
|
||||
--max-conflicts 0 \
|
||||
--apply
|
||||
|
||||
# Salida esperada tras migracion exitosa:
|
||||
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar.
|
||||
- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162.
|
||||
- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1.
|
||||
- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861.
|
||||
- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos.
|
||||
- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect <container> | jq '.[].Mounts'`.
|
||||
- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos.
|
||||
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bash
|
||||
# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas.
|
||||
# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold,
|
||||
# y solo ejecuta la migracion real cuando se pasa --apply.
|
||||
#
|
||||
# Usage:
|
||||
# mas_syn2mas_migration --ssh-host <host> --mas-container <name> \
|
||||
# --synapse-config-path <path-on-host> --log-dir <local-path> \
|
||||
# [--max-conflicts N] [--apply]
|
||||
#
|
||||
# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mas_syn2mas_migration() {
|
||||
local ssh_host=""
|
||||
local mas_container=""
|
||||
local synapse_config_path=""
|
||||
local log_dir=""
|
||||
local max_conflicts=0
|
||||
local do_apply=false
|
||||
|
||||
# ---- Parse args ----
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ssh-host)
|
||||
ssh_host="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mas-container)
|
||||
mas_container="$2"
|
||||
shift 2
|
||||
;;
|
||||
--synapse-config-path)
|
||||
synapse_config_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--log-dir)
|
||||
log_dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--max-conflicts)
|
||||
max_conflicts="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
do_apply=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat >&2 <<'USAGE'
|
||||
mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS)
|
||||
|
||||
Usage:
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host <host> \
|
||||
--mas-container <name> \
|
||||
--synapse-config-path <path-on-host> \
|
||||
--log-dir <local-path> \
|
||||
[--max-conflicts N] \
|
||||
[--apply]
|
||||
|
||||
Opciones:
|
||||
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
|
||||
--mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1)
|
||||
--synapse-config-path Ruta en el VPS al homeserver.yaml
|
||||
(ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml)
|
||||
--log-dir Directorio local donde archivar logs dry-run y apply
|
||||
--max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0)
|
||||
--apply Ejecutar migracion real. Sin esta flag: solo dry-run.
|
||||
|
||||
Comportamiento:
|
||||
1. Siempre ejecuta dry-run primero y archiva el log.
|
||||
2. Si conflicts > max-conflicts -> status=aborted, exit 2.
|
||||
3. Sin --apply -> status=ok (dry-run completado), exit 0.
|
||||
4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion.
|
||||
|
||||
Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N}
|
||||
USAGE
|
||||
echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}'
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "mas_syn2mas_migration: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- Validar argumentos obligatorios ----
|
||||
local errors=()
|
||||
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
|
||||
[[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio")
|
||||
[[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio")
|
||||
[[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio")
|
||||
|
||||
if [[ ${#errors[@]} -gt 0 ]]; then
|
||||
for err in "${errors[@]}"; do
|
||||
echo "ERROR: $err" >&2
|
||||
done
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar que max_conflicts es un entero no negativo
|
||||
if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Dependencias locales ----
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2
|
||||
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ---- Crear log-dir con permisos restringidos ----
|
||||
mkdir -p "$log_dir"
|
||||
chmod 0700 "$log_dir"
|
||||
|
||||
local ts
|
||||
ts=$(date +%s)
|
||||
|
||||
local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log"
|
||||
local apply_log_path="null"
|
||||
local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log"
|
||||
|
||||
# La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config
|
||||
# MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real
|
||||
# puede variar — usamos la ruta tal como existe en el host (montada via volume).
|
||||
# El comando real esperado: docker exec <container> mas-cli syn2mas --synapse-config <path>
|
||||
# donde <path> es la ruta tal como el container la ve (via volume mount).
|
||||
# Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container.
|
||||
local container_config="/data/homeserver.yaml"
|
||||
|
||||
echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 1: DRY-RUN obligatorio
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando dry-run..." >&2
|
||||
|
||||
local dry_exit=0
|
||||
# Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing
|
||||
local dry_output
|
||||
dry_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}' \
|
||||
--dry-run" \
|
||||
2>&1) || dry_exit=$?
|
||||
|
||||
# Archivar log con timestamp + header informativo
|
||||
{
|
||||
echo "# mas_syn2mas_migration dry-run"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${dry_exit}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$dry_output"
|
||||
} > "$dry_run_log"
|
||||
chmod 0600 "$dry_run_log"
|
||||
|
||||
echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2
|
||||
|
||||
if [[ $dry_exit -ne 0 ]]; then
|
||||
# Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad)
|
||||
echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2
|
||||
local escaped_out
|
||||
escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.')
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 2: Parsear conflicts del dry-run
|
||||
# =========================================================================
|
||||
# Regex sobre lineas tipo:
|
||||
# "Conflict:", "Skipping:", "Error processing user", "conflict"
|
||||
# También contamos líneas que indiquen usuarios problemáticos.
|
||||
local conflicts=0
|
||||
local conflict_lines
|
||||
conflict_lines=$(printf '%s\n' "$dry_output" | \
|
||||
grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true)
|
||||
|
||||
# grep -c devuelve string; convertir a int defensivamente
|
||||
if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then
|
||||
conflicts=$conflict_lines
|
||||
else
|
||||
# Parser falló de forma inesperada — abortar defensivamente
|
||||
echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 3: Verificar threshold de conflicts
|
||||
# =========================================================================
|
||||
if [[ $conflicts -gt $max_conflicts ]]; then
|
||||
echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2
|
||||
echo "Revisar: ${dry_run_log}" >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado)
|
||||
# =========================================================================
|
||||
if [[ "$do_apply" == "false" ]]; then
|
||||
echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2
|
||||
local dry_run_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 5: Migracion REAL (--apply)
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2
|
||||
local apply_start
|
||||
apply_start=$(date +%s)
|
||||
|
||||
local apply_exit=0
|
||||
local apply_output
|
||||
apply_output=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas \
|
||||
--synapse-config '${container_config}'" \
|
||||
2>&1) || apply_exit=$?
|
||||
|
||||
local apply_end
|
||||
apply_end=$(date +%s)
|
||||
local duration_s=$(( apply_end - apply_start ))
|
||||
|
||||
# Archivar log de apply
|
||||
{
|
||||
echo "# mas_syn2mas_migration apply"
|
||||
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
|
||||
echo "# synapse-config-path=${synapse_config_path}"
|
||||
echo "# exit=${apply_exit} duration_s=${duration_s}"
|
||||
echo "# ---"
|
||||
printf '%s\n' "$apply_output"
|
||||
} > "$apply_log_file"
|
||||
chmod 0600 "$apply_log_file"
|
||||
|
||||
apply_log_path="$apply_log_file"
|
||||
echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2
|
||||
|
||||
if [[ $apply_exit -ne 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# =========================================================================
|
||||
# PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse
|
||||
# =========================================================================
|
||||
echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2
|
||||
|
||||
local mas_user_count=0
|
||||
local synapse_user_count=0
|
||||
local users_migrated=0
|
||||
local post_status="ok"
|
||||
|
||||
# Contar usuarios en MAS via mas-cli admin user list
|
||||
local mas_count_raw
|
||||
mas_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
mas_user_count=$mas_count_raw
|
||||
else
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2
|
||||
post_status="ok" # No abortar, solo advertir
|
||||
fi
|
||||
|
||||
# Contar usuarios locales en Synapse via psql (excluyendo bots/AS)
|
||||
# Intentamos obtener el count; si falla, continuamos sin abortar
|
||||
local synapse_count_raw
|
||||
synapse_count_raw=$(ssh "$ssh_host" \
|
||||
"docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \
|
||||
2>/dev/null || echo "0")
|
||||
|
||||
if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then
|
||||
synapse_user_count=$synapse_count_raw
|
||||
fi
|
||||
|
||||
users_migrated=$mas_user_count
|
||||
|
||||
# Si tenemos ambos counts y difieren significativamente, marcar como warning en log
|
||||
if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then
|
||||
echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2
|
||||
post_status="error"
|
||||
fi
|
||||
|
||||
echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2
|
||||
|
||||
# =========================================================================
|
||||
# PASO 7: Emitir JSON final
|
||||
# =========================================================================
|
||||
local dry_run_log_json apply_log_json
|
||||
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
|
||||
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
|
||||
|
||||
echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (no sourced)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
mas_syn2mas_migration "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para mas_syn2mas_migration
|
||||
# Verifica arg parsing sin conectar al VPS real.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/mas_syn2mas_migration.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_exit() {
|
||||
local test_name="$1" expected_exit="$2"
|
||||
shift 2
|
||||
local actual_exit=0
|
||||
set +e
|
||||
"$@" >/dev/null 2>&1
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if [[ "$actual_exit" == "$expected_exit" ]]; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
assert_stdout_contains() {
|
||||
local test_name="$1" needle="$2"
|
||||
shift 2
|
||||
local output actual_exit=0
|
||||
set +e
|
||||
output=$("$@" 2>/dev/null)
|
||||
actual_exit=$?
|
||||
set -e
|
||||
if echo "$output" | grep -q "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
((PASS++)) || true
|
||||
else
|
||||
echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output"
|
||||
((FAIL++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: aborta con error cuando faltan args obligatorios
|
||||
assert_exit "aborta con error cuando faltan args obligatorios" 1 \
|
||||
mas_syn2mas_migration
|
||||
|
||||
# Test: help no devuelve error
|
||||
assert_exit "help no devuelve error" 0 \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: argumento desconocido retorna exit 1
|
||||
assert_exit "argumento desconocido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration --unknown-flag
|
||||
|
||||
# Test: max-conflicts invalido retorna exit 1
|
||||
assert_exit "max-conflicts invalido retorna exit 1" 1 \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$" \
|
||||
--max-conflicts "not-a-number"
|
||||
|
||||
# Test: help emite JSON valido con status=help
|
||||
assert_stdout_contains "help emite JSON con status help" '"status":"help"' \
|
||||
mas_syn2mas_migration --help
|
||||
|
||||
# Test: falta --ssh-host emite JSON con status=error
|
||||
assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml \
|
||||
--log-dir "/tmp/test_mas_migration_$$"
|
||||
|
||||
# Test: falta --log-dir emite JSON con status=error
|
||||
assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \
|
||||
mas_syn2mas_migration \
|
||||
--ssh-host fake-host \
|
||||
--mas-container fake-container \
|
||||
--synapse-config-path /fake/homeserver.yaml
|
||||
|
||||
# Limpieza
|
||||
rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -32,16 +32,16 @@ file_path: "bash/functions/infra/pre_commit_hook_install.sh"
|
||||
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
|
||||
pre_commit_hook_install $HOME/fn_registry
|
||||
# INSTALLED $HOME/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)
|
||||
pre_commit_hook_install $HOME/fn_registry
|
||||
# SKIP $HOME/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
|
||||
pre_commit_hook_install $HOME/fn_registry --force
|
||||
# INSTALLED $HOME/fn_registry/.git/hooks/pre-commit
|
||||
```
|
||||
|
||||
## Notas
|
||||
@@ -58,5 +58,5 @@ Si no puede localizar `fn_registry`, el hook imprime un aviso y sale con exit 0
|
||||
|
||||
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
|
||||
export FN_REGISTRY_ROOT=$HOME/fn_registry
|
||||
```
|
||||
|
||||
@@ -28,13 +28,13 @@ output: "Una linea TAB-separada '<app_name>\\t<absolute_dir_path>' en stdout. En
|
||||
|
||||
```bash
|
||||
# Desde dentro de cpp/apps/chart_demo/
|
||||
cd /home/lucas/fn_registry/cpp/apps/chart_demo
|
||||
cd $HOME/fn_registry/cpp/apps/chart_demo
|
||||
resolve_cpp_app_dir
|
||||
# -> chart_demo\t/home/lucas/fn_registry/cpp/apps/chart_demo
|
||||
# -> chart_demo\t$HOME/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
|
||||
# -> registry_dashboard\t$HOME/fn_registry/cpp/apps/registry_dashboard
|
||||
|
||||
# Capturar los dos campos
|
||||
resolved=$(resolve_cpp_app_dir graph_explorer)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
resolve_cpp_app_dir() {
|
||||
local app_arg="${1:-}"
|
||||
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
local root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||
|
||||
_list_cpp_apps() {
|
||||
ls "$root/apps/" 2>/dev/null | sed 's/^/ apps\//'
|
||||
|
||||
@@ -39,7 +39,7 @@ echo "$result"
|
||||
# {"files_transferred": 12, "total_size": "1.23 MB", "ssh_alias": "prod-server", "remote_dir": "/opt/apps/dag_engine"}
|
||||
|
||||
# Deploy con ruta absoluta local
|
||||
rsync_deploy "/home/lucas/fn_registry/apps/myapp/" "myserver" "/opt/myapp"
|
||||
rsync_deploy "$HOME/fn_registry/apps/myapp/" "myserver" "/opt/myapp"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: start_nordvpn_socks_bridge
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "start_nordvpn_socks_bridge([--port N] [--socks-host HOST] [--socks-port N] [--user U] [--pass P]) -> JSON"
|
||||
description: "Levanta un proxy HTTP local sin auth que reenvía al servidor SOCKS5 de NordVPN con auth usando gost v3. Resuelve la limitación de Chrome, que no soporta SOCKS5-con-auth: el navegador apunta a http://127.0.0.1:<port> (sin auth) y el tráfico sale por NordVPN. Idempotente: si el puerto ya escucha, no relanza."
|
||||
tags: [navegator, vpn, proxy, nordvpn, socks5, gost, chrome, cdp]
|
||||
params:
|
||||
- name: "--port"
|
||||
desc: "Puerto HTTP local del bridge (default 8889)"
|
||||
- name: "--socks-host"
|
||||
desc: "Servidor SOCKS5 de NordVPN (default socks-nl1.nordvpn.com)"
|
||||
- name: "--socks-port"
|
||||
desc: "Puerto del servidor SOCKS5 (default 1080)"
|
||||
- name: "--user"
|
||||
desc: "Service username de NordVPN. Si se omite, lee NORDVPN_SOCKS_USER del entorno"
|
||||
- name: "--pass"
|
||||
desc: "Service password de NordVPN. Si se omite, lee NORDVPN_SOCKS_PASS del entorno"
|
||||
output: "JSON en stdout: {proxy_url, pid, socks_host, status}. status puede ser 'running' (lanzado ahora) o 'already_running' (puerto ya escuchaba). Errores a stderr + exit 1."
|
||||
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/start_nordvpn_socks_bridge.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Con env vars (recomendado para scripts):
|
||||
NORDVPN_SOCKS_USER=xxx NORDVPN_SOCKS_PASS=yyy \
|
||||
bash bash/functions/infra/start_nordvpn_socks_bridge.sh \
|
||||
--port 8889 \
|
||||
--socks-host socks-nl1.nordvpn.com
|
||||
|
||||
# Salida (primera vez):
|
||||
# {"proxy_url":"http://127.0.0.1:8889","pid":12345,"socks_host":"socks-nl1.nordvpn.com","status":"running"}
|
||||
|
||||
# Salida (idempotente, ya corría):
|
||||
# {"proxy_url":"http://127.0.0.1:8889","pid":null,"socks_host":"socks-nl1.nordvpn.com","status":"already_running"}
|
||||
|
||||
# Luego Chrome (o el flujo CDP del navegator) apunta al bridge:
|
||||
# chrome.exe --proxy-server=http://127.0.0.1:8889
|
||||
|
||||
# Verificar que el tráfico sale por NordVPN:
|
||||
# curl -x http://127.0.0.1:8889 https://api.ipify.org
|
||||
# -> 109.202.99.x (IP NordVPN NL, no la IP de casa)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas que Chrome (o cualquier app que solo acepta proxy HTTP sin auth) salga por NordVPN, pero NordVPN solo ofrece SOCKS5-con-auth. Chrome no soporta SOCKS5-with-authentication — este bridge actúa de intermediario sin auth local. Útil especialmente en el flujo CDP del navegator (cdp-cli + agente browser) cuando quieres que el browser de automatización salga con IP NordVPN para evadir DPI del ISP o geo-bloqueos, sin exponer las credenciales NordVPN al proceso del browser.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **NordVPN SOCKS5 exige service credentials**, no el usuario/contraseña de la cuenta NordVPN. Se obtienen en dashboard.nordvpn.com → Manual Setup → Service credentials.
|
||||
- **Chrome no soporta SOCKS5-auth** nativamente (a diferencia de Firefox que sí). Por eso este bridge HTTP-sin-auth es necesario.
|
||||
- **gost escucha en todas las interfaces** (`-L http://:PORT`). El puerto local NO tiene autenticación. No exponer en redes no confiables. Para bind solo a loopback cambiar el flag a `-L http://127.0.0.1:PORT` en el script si es necesario.
|
||||
- **Servidores NordVPN SOCKS5 disponibles**: `socks-nl1..8.nordvpn.com`, `socks-de1..4.nordvpn.com`, `socks-us1..8.nordvpn.com`, etc. La lista completa en el dashboard de NordVPN.
|
||||
- **Si gost no está instalado**: se descarga automáticamente `gost v3.0.0 linux amd64` a `~/.local/bin/gost`. Requiere curl y tar.
|
||||
- **Log**: en `/tmp/nordvpn_socks_bridge_<port>.log`. Consultar si el bridge no arranca.
|
||||
- **PID null en already_running**: cuando el puerto ya escuchaba, el PID del proceso no se recupera (habría que hacer `lsof`/`ss` para identificarlo).
|
||||
- **Consumidor principal**: flujo `navegator`/CDP — ver `docs/capabilities/navegator.md`. El agente browser lanza este bridge antes de abrir Chrome con `--proxy-server=http://127.0.0.1:<port>`.
|
||||
- **Gotcha invocación desde el Bash tool de Claude (exit 144)**: el script deja gost en background (`nohup ... & disown`); ese daemon retiene el pipe de stdout del tool → el harness mata el proceso con SIGSTKFLT (exit 144) AUNQUE el bridge SÍ arranca bien. Lanzar con `run_in_background:true` o redirigiendo todo (`>/tmp/x 2>&1 </dev/null`) para evitarlo. En terminal real (o `fn run` interactivo) no ocurre. Verificado 2026-05-30: el bridge queda corriendo y funcional pese al 144.
|
||||
- **Windows→WSL**: si Chrome corre en Windows (chrome.exe) y gost en WSL2, Chrome alcanza `127.0.0.1:<port>` vía localhostForwarding de WSL2. Verificar con `curl -x http://127.0.0.1:<port> https://api.ipify.org` desde ambos lados.
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# start_nordvpn_socks_bridge — Levanta proxy HTTP local sin auth que reenvía a SOCKS5 NordVPN con auth via gost v3.
|
||||
# Resuelve la limitacion de Chrome que no soporta SOCKS5-con-auth.
|
||||
set -euo pipefail
|
||||
|
||||
# --- defaults ---
|
||||
PORT=8889
|
||||
SOCKS_HOST="socks-nl1.nordvpn.com"
|
||||
SOCKS_PORT=1080
|
||||
VPN_USER="${NORDVPN_SOCKS_USER:-}"
|
||||
VPN_PASS="${NORDVPN_SOCKS_PASS:-}"
|
||||
|
||||
# --- parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--socks-host) SOCKS_HOST="$2"; shift 2 ;;
|
||||
--socks-port) SOCKS_PORT="$2"; shift 2 ;;
|
||||
--user) VPN_USER="$2"; shift 2 ;;
|
||||
--pass) VPN_PASS="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- validate creds ---
|
||||
if [[ -z "$VPN_USER" ]]; then
|
||||
echo "error: NORDVPN_SOCKS_USER not set. Use --user or export NORDVPN_SOCKS_USER" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$VPN_PASS" ]]; then
|
||||
echo "error: NORDVPN_SOCKS_PASS not set. Use --pass or export NORDVPN_SOCKS_PASS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG_FILE="/tmp/nordvpn_socks_bridge_${PORT}.log"
|
||||
|
||||
# --- check idempotencia: ya escucha? ---
|
||||
if ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":null,\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"already_running\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- asegurar gost ---
|
||||
GOST_BIN=""
|
||||
if command -v gost &>/dev/null; then
|
||||
GOST_BIN="$(command -v gost)"
|
||||
elif [[ -x "$HOME/.local/bin/gost" ]]; then
|
||||
GOST_BIN="$HOME/.local/bin/gost"
|
||||
else
|
||||
echo "gost not found, downloading v3.0.0 linux amd64..." >&2
|
||||
GOST_URL="https://github.com/go-gost/gost/releases/download/v3.0.0/gost_3.0.0_linux_amd64.tar.gz"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
curl -fsSL "$GOST_URL" -o "$TMP_DIR/gost.tar.gz" >&2
|
||||
tar -xzf "$TMP_DIR/gost.tar.gz" -C "$TMP_DIR" >&2
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cp "$TMP_DIR/gost" "$HOME/.local/bin/gost"
|
||||
chmod +x "$HOME/.local/bin/gost"
|
||||
rm -rf "$TMP_DIR"
|
||||
GOST_BIN="$HOME/.local/bin/gost"
|
||||
echo "gost installed to $GOST_BIN" >&2
|
||||
fi
|
||||
|
||||
# --- url-encode user y pass (puede tener caracteres especiales) ---
|
||||
url_encode() {
|
||||
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"
|
||||
}
|
||||
|
||||
ENC_USER="$(url_encode "$VPN_USER")"
|
||||
ENC_PASS="$(url_encode "$VPN_PASS")"
|
||||
|
||||
# --- lanzar gost en background ---
|
||||
nohup "$GOST_BIN" \
|
||||
-L "http://:${PORT}" \
|
||||
-F "socks5://${ENC_USER}:${ENC_PASS}@${SOCKS_HOST}:${SOCKS_PORT}" \
|
||||
>"$LOG_FILE" 2>&1 &
|
||||
GOST_PID=$!
|
||||
disown $GOST_PID
|
||||
|
||||
# --- esperar ~2s y verificar que el puerto escucha ---
|
||||
sleep 2
|
||||
if ! ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
|
||||
echo "error: gost did not start. Last lines of $LOG_FILE:" >&2
|
||||
tail -10 "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":${GOST_PID},\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"running\"}"
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: wg_client_install
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_client_install(config_path_or_stdin, [interface_name]) -> json"
|
||||
description: "Device-side: instala wg0.conf en /etc/wireguard/, habilita systemd wg-quick@wg0, verifica handshake con hub. Idempotente. Acepta config por path o stdin (para pipes desde wg_client_config)."
|
||||
tags: [wireguard, client, install, mesh, systemd]
|
||||
uses_functions: [wg_install_bash_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: config_path_or_stdin
|
||||
desc: "path al archivo .conf existente, o '-' para leer de stdin (compatible con pipe desde wg_client_config)"
|
||||
- name: interface_name
|
||||
desc: "nombre de la interfaz WireGuard (default: wg0). Determina /etc/wireguard/<iface>.conf y la unit systemd wg-quick@<iface>"
|
||||
output: "JSON {status, interface, hub_endpoint, handshake_seen}. status: installed | already-configured | installed-no-handshake | installed-no-systemd"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_client_install.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/wg_client_install.sh
|
||||
|
||||
# Desde pipe (caso más común en flow 0009):
|
||||
wg_client_config_go_infra | jq -r '.INI' | wg_client_install -
|
||||
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Desde archivo .conf generado previamente:
|
||||
wg_client_install /tmp/peer_laptop.conf
|
||||
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Con interfaz personalizada:
|
||||
wg_client_install /tmp/peer_laptop.conf wg1
|
||||
# {"status":"installed","interface":"wg1","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
|
||||
|
||||
# Segunda ejecución con misma config (idempotente):
|
||||
wg_client_install /tmp/peer_laptop.conf
|
||||
# {"status":"already-configured","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":false}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites conectar un nuevo peer al mesh WireGuard en el flow 0009. Úsala justo después de `wg_client_config` (que genera el .conf) para instalarlo en el device peer. Es el paso final del onboarding de un nodo: config generada → instalada → verificada con handshake.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere root/sudo** para escribir en `/etc/wireguard/`, hacer `chmod 600`, y ejecutar `systemctl`. El operador debe tener `sudo` sin password para estos comandos, o ejecutar la función como root.
|
||||
- **Idempotente por contenido**: si `/etc/wireguard/<iface>.conf` ya existe con el mismo contenido, retorna `status=already-configured` sin tocar nada. Si el contenido difiere, hace backup automático con timestamp antes de sobreescribir.
|
||||
- **NetworkManager**: si NM gestiona la interfaz wg0, `wg-quick` puede fallar con conflicto. Solución: crear `/etc/NetworkManager/conf.d/99-wg.conf` con `[keyfile]\nunmanaged-devices=interface-name:wg0` y reiniciar NM antes de ejecutar esta función.
|
||||
- **WSL2 sin systemd** (variantes antiguas o sin `/etc/wsl.conf` con `[boot] systemd=true`): `systemctl` no está disponible. La función detecta esto, emite `status=installed-no-systemd` con instrucciones en stderr para levantar la interfaz manualmente con `sudo wg-quick up wg0`. Para autostart en WSL2 sin systemd: añadir `sudo wg-quick up wg0` al final de `~/.bashrc`.
|
||||
- **WSL2 con systemd**: kernel WSL2 >= 5.6 (default en distros recientes) incluye WireGuard built-in. Habilitar systemd en WSL2 con `[boot]\nsystemd=true` en `/etc/wsl.conf` y reiniciar WSL. Luego esta función funciona igual que en Linux nativo.
|
||||
- **Android / Termux**: NO usar esta función. Termux no tiene systemd ni `/etc/wireguard/`. En Android usar la app WireGuard oficial (F-Droid / Play Store) e importar el .conf generado por `wg_client_config` directamente desde la app.
|
||||
- **handshake_seen=false con status=installed-no-handshake**: la interfaz está activa pero el hub no ha respondido en 10s. No es un error fatal — puede tardar más si el hub está ocupado o hay NAT traversal pendiente. Verificar: endpoint accesible por UDP, hub corriendo con `wg show`, claves public/preshared coincidentes.
|
||||
- Los logs van siempre a stderr con prefijo `[wg_client_install]`; stdout es exclusivamente el JSON de resultado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_client_install — Device-side: instala wg0.conf en /etc/wireguard/, habilita
|
||||
# systemd wg-quick@<iface>, verifica handshake con hub. Idempotente.
|
||||
# Acepta config por path o stdin ("-").
|
||||
# Exit 0 = éxito (installed o already-configured), 1 = error fatal.
|
||||
|
||||
wg_client_install() {
|
||||
local config_src="${1:--}"
|
||||
local iface="${2:-wg0}"
|
||||
local conf_dest="/etc/wireguard/${iface}.conf"
|
||||
local config_content="" hub_endpoint="" handshake_seen="false"
|
||||
|
||||
_wg_ci_log() { echo "[wg_client_install] $*" >&2; }
|
||||
|
||||
# ── Prereq: wg debe estar instalado ──────────────────────────────────────
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_ci_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Leer contenido del .conf ──────────────────────────────────────────────
|
||||
if [[ "${config_src}" == "-" ]]; then
|
||||
_wg_ci_log "Leyendo config desde stdin"
|
||||
config_content=$(cat) || { _wg_ci_log "ERROR: fallo al leer stdin"; return 1; }
|
||||
elif [[ -f "${config_src}" ]]; then
|
||||
_wg_ci_log "Leyendo config desde ${config_src}"
|
||||
config_content=$(cat "${config_src}") || { _wg_ci_log "ERROR: fallo al leer ${config_src}"; return 1; }
|
||||
else
|
||||
_wg_ci_log "ERROR: '${config_src}' no es un path existente ni '-' (stdin)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "${config_content}" ]]; then
|
||||
_wg_ci_log "ERROR: contenido de config vacío"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Extraer endpoint del hub para incluirlo en el JSON de salida ──────────
|
||||
hub_endpoint=$(printf '%s\n' "${config_content}" | grep -m1 '^Endpoint\s*=' | sed 's/.*=\s*//' | tr -d '[:space:]' || true)
|
||||
|
||||
# ── Idempotencia: comparar con conf existente ─────────────────────────────
|
||||
if [[ -f "${conf_dest}" ]]; then
|
||||
local existing_content
|
||||
existing_content=$(sudo cat "${conf_dest}" 2>/dev/null || cat "${conf_dest}" 2>/dev/null || true)
|
||||
if [[ "${existing_content}" == "${config_content}" ]]; then
|
||||
_wg_ci_log "Configuración idéntica ya presente en ${conf_dest}; nada que hacer"
|
||||
printf '{"status":"already-configured","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
# Contenido difiere → backup + rewrite
|
||||
local backup="${conf_dest}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
_wg_ci_log "Configuración existente difiere; backup → ${backup}"
|
||||
sudo cp "${conf_dest}" "${backup}" \
|
||||
|| { _wg_ci_log "ERROR: no se pudo hacer backup de ${conf_dest}"; return 1; }
|
||||
fi
|
||||
|
||||
# ── Crear directorio y escribir conf ─────────────────────────────────────
|
||||
sudo mkdir -p "/etc/wireguard" \
|
||||
|| { _wg_ci_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
|
||||
|
||||
printf '%s\n' "${config_content}" | sudo tee "${conf_dest}" >/dev/null \
|
||||
|| { _wg_ci_log "ERROR: no se pudo escribir ${conf_dest}"; return 1; }
|
||||
|
||||
sudo chmod 600 "${conf_dest}" \
|
||||
|| { _wg_ci_log "WARN: no se pudo chmod 600 ${conf_dest}"; }
|
||||
|
||||
_wg_ci_log "Config escrita en ${conf_dest} (chmod 600)"
|
||||
|
||||
# ── Habilitar + arrancar systemd unit ─────────────────────────────────────
|
||||
if ! command -v systemctl &>/dev/null; then
|
||||
_wg_ci_log "WARN: systemctl no disponible."
|
||||
_wg_ci_log " En WSL2 sin systemd: ejecuta 'sudo wg-quick up ${iface}' manualmente."
|
||||
_wg_ci_log " Para autostart en WSL2: añade 'sudo wg-quick up ${iface}' a ~/.bashrc o usa WSL2 con systemd habilitado."
|
||||
printf '{"status":"installed-no-systemd","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_wg_ci_log "Habilitando y arrancando wg-quick@${iface}"
|
||||
if ! sudo systemctl enable --now "wg-quick@${iface}" 2>&1 | tee /dev/stderr >&2; then
|
||||
_wg_ci_log "ERROR: systemctl enable --now wg-quick@${iface} falló."
|
||||
_wg_ci_log " En WSL2: asegúrate de tener kernel >= 5.6 y systemd habilitado (/etc/wsl.conf: [boot] systemd=true)."
|
||||
_wg_ci_log " Si NetworkManager gestiona ${iface}: añade 'unmanaged-devices=interface-name:${iface}' a /etc/NetworkManager/conf.d/99-wg.conf"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_ci_log "wg-quick@${iface} habilitado y activo"
|
||||
|
||||
# ── Esperar handshake (hasta 10 s) ────────────────────────────────────────
|
||||
local deadline=$(( $(date +%s) + 10 ))
|
||||
_wg_ci_log "Esperando handshake en ${iface} (timeout 10s)..."
|
||||
while [[ $(date +%s) -lt ${deadline} ]]; do
|
||||
local hs_output
|
||||
hs_output=$(sudo wg show "${iface}" latest-handshakes 2>/dev/null || true)
|
||||
# latest-handshakes devuelve "<pubkey> <unix_ts>"; ts > 0 = handshake visto
|
||||
if printf '%s\n' "${hs_output}" | awk '{print $2}' | grep -qE '^[1-9][0-9]+$'; then
|
||||
handshake_seen="true"
|
||||
_wg_ci_log "Handshake confirmado en ${iface}"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${handshake_seen}" == "false" ]]; then
|
||||
_wg_ci_log "WARN: timeout esperando handshake en ${iface}. La interfaz está activa pero el hub no ha respondido aún."
|
||||
_wg_ci_log " Verifica: endpoint accesible, hub corriendo, claves correctas."
|
||||
printf '{"status":"installed-no-handshake","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '{"status":"installed","interface":"%s","hub_endpoint":"%s","handshake_seen":true}\n' \
|
||||
"${iface}" "${hub_endpoint}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: wg_hub_setup
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_hub_setup(private_key, subnet_cidr, listen_port) -> json"
|
||||
description: "Configura el host como hub WireGuard (servidor). Crea /etc/wireguard/wg0.conf con clave privada + IP pool + ListenPort. Abre UDP en firewall (ufw o iptables), habilita ip_forward persistente en /etc/sysctl.d/99-wireguard.conf, persiste y arranca systemd unit wg-quick@wg0. Idempotente: misma PrivateKey = no-op; PrivateKey distinta = backup + rewrite."
|
||||
tags: [wireguard, hub, infra, mesh, systemd]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: private_key
|
||||
desc: "base64 WG private key del hub (44 chars, generada por wg_keygen o `wg genkey`)"
|
||||
- name: subnet_cidr
|
||||
desc: "subnet hub con bits del host, ej. 10.42.0.1/24. El hub recibe la .1"
|
||||
- name: listen_port
|
||||
desc: "UDP port donde escucha WireGuard (default 51820, rango 1024-65535)"
|
||||
output: "JSON {status, config_path, interface, hub_ip}. status: configured | reconfigured | already-configured"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_hub_setup.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Generar clave (o usar wg_keygen del registry)
|
||||
PRIVKEY=$(wg genkey)
|
||||
|
||||
source bash/functions/infra/wg_hub_setup.sh
|
||||
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
|
||||
# Segunda ejecución con la misma clave → no-op
|
||||
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"already-configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
|
||||
# Cambiar clave → backup de conf anterior + rewrite
|
||||
wg_hub_setup "$NUEVA_PRIVKEY" "10.42.0.1/24" 51820
|
||||
# {"status":"reconfigured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites convertir un VPS/host en el nodo central (hub) de una red mesh WireGuard. Úsala inmediatamente después de `wg_install` para dejar el hub listo para recibir peers. El hub escucha en un puerto UDP público; los peers se conectan a él con su propia clave y la AllowedIPs del hub.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `sudo` con NOPASSWD para: `tee /etc/wireguard/`, `chmod`, `sysctl`, `iptables`/`ufw`, `systemctl`. Configurar antes en sudoers.
|
||||
- NUNCA reusar la misma `private_key` entre hubs distintos. Cada hub tiene su propio par de claves independiente.
|
||||
- El bloque `PostUp`/`PostDown` usa `eth0` como interfaz de salida para NAT. En VPS con interfaz distinta (ens3, enp3s0) editar `/etc/wireguard/wg0.conf` manualmente antes de reiniciar.
|
||||
- Conflicto de subnet con docker0 si usas 172.17.0.0/16. Evitar solapamiento — usar 10.42.x.x o 192.168.200.x para WireGuard.
|
||||
- `systemd-resolved` en VPS Ubuntu puede interferir con resolución DNS cuando WireGuard está activo si el conf añade `DNS =`. Esta función NO setea DNS para evitar el problema — configurarlo a nivel peer si se necesita.
|
||||
- Si `systemctl start wg-quick@wg0` falla, revisar logs con `journalctl -u wg-quick@wg0 -n 50`.
|
||||
- En entornos cloud (AWS/GCP/Azure) el security group / firewall de red del proveedor también debe abrir el puerto UDP, independientemente de ufw/iptables local.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_hub_setup — Configura el host como hub WireGuard (servidor central).
|
||||
# Crea /etc/wireguard/wg0.conf con [Interface] block, abre UDP en firewall,
|
||||
# habilita ip_forward persistente, arranca y verifica wg-quick@wg0.
|
||||
# Idempotente: si el conf existe con la misma PrivateKey -> no-op.
|
||||
# Emite JSON a stdout. Logs a stderr con prefijo [wg_hub_setup].
|
||||
# Exit 0 = éxito, 1 = fallo.
|
||||
|
||||
wg_hub_setup() {
|
||||
local private_key="${1:-}"
|
||||
local subnet_cidr="${2:-10.42.0.1/24}"
|
||||
local listen_port="${3:-51820}"
|
||||
|
||||
_wg_hub_log() { echo "[wg_hub_setup] $*" >&2; }
|
||||
|
||||
# ── Validación de entradas ──────────────────────────────────────────────
|
||||
|
||||
# private_key: base64 estándar de 44 caracteres (32 bytes)
|
||||
if [[ -z "${private_key}" ]]; then
|
||||
_wg_hub_log "ERROR: private_key requerida (base64 44 chars, generada por wg genkey)"
|
||||
return 1
|
||||
fi
|
||||
if ! [[ "${private_key}" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
|
||||
_wg_hub_log "ERROR: private_key no parece base64 válida (se esperan 44 chars terminando en '=')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# subnet_cidr: 10.x.x.x/nn
|
||||
if ! [[ "${subnet_cidr}" =~ ^10\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
|
||||
_wg_hub_log "ERROR: subnet_cidr debe ser 10.x.x.x/nn, recibido: '${subnet_cidr}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# listen_port: 1024-65535
|
||||
if ! [[ "${listen_port}" =~ ^[0-9]+$ ]] || (( listen_port < 1024 || listen_port > 65535 )); then
|
||||
_wg_hub_log "ERROR: listen_port debe ser un entero entre 1024 y 65535, recibido: '${listen_port}'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Verificar que wireguard-tools esté instalado ────────────────────────
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_hub_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v wg-quick &>/dev/null; then
|
||||
_wg_hub_log "ERROR: 'wg-quick' no encontrado. Instala wireguard-tools."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Extraer hub_ip (parte sin CIDR prefix) y determinar config_path ────
|
||||
local hub_ip="${subnet_cidr%%/*}"
|
||||
local config_path="/etc/wireguard/wg0.conf"
|
||||
local interface="wg0"
|
||||
local action_status=""
|
||||
|
||||
# ── Idempotencia: comparar PrivateKey existente ─────────────────────────
|
||||
if [[ -f "${config_path}" ]]; then
|
||||
local existing_key
|
||||
existing_key=$(sudo grep -E '^\s*PrivateKey\s*=' "${config_path}" 2>/dev/null \
|
||||
| head -n1 | sed 's/.*=\s*//')
|
||||
if [[ "${existing_key}" == "${private_key}" ]]; then
|
||||
_wg_hub_log "Config existente con misma PrivateKey — no-op (status=already-configured)"
|
||||
printf '{"status":"already-configured","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
|
||||
"${config_path}" "${interface}" "${hub_ip}"
|
||||
return 0
|
||||
else
|
||||
_wg_hub_log "Config existente con PrivateKey DIFERENTE — haciendo backup y reescribiendo"
|
||||
local backup_path="${config_path}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
sudo cp "${config_path}" "${backup_path}" \
|
||||
|| { _wg_hub_log "ERROR: no se pudo hacer backup en ${backup_path}"; return 1; }
|
||||
_wg_hub_log "Backup guardado en ${backup_path}"
|
||||
action_status="reconfigured"
|
||||
fi
|
||||
else
|
||||
action_status="configured"
|
||||
fi
|
||||
|
||||
# ── Asegurar que /etc/wireguard existe con permisos correctos ───────────
|
||||
if [[ ! -d /etc/wireguard ]]; then
|
||||
sudo mkdir -p /etc/wireguard \
|
||||
|| { _wg_hub_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
|
||||
sudo chmod 700 /etc/wireguard
|
||||
_wg_hub_log "Directorio /etc/wireguard creado"
|
||||
fi
|
||||
|
||||
# ── Escribir /etc/wireguard/wg0.conf ────────────────────────────────────
|
||||
_wg_hub_log "Escribiendo ${config_path} (Address=${subnet_cidr}, ListenPort=${listen_port})"
|
||||
sudo tee "${config_path}" > /dev/null <<EOF
|
||||
[Interface]
|
||||
Address = ${subnet_cidr}
|
||||
ListenPort = ${listen_port}
|
||||
PrivateKey = ${private_key}
|
||||
SaveConfig = false
|
||||
|
||||
# NAT: permite que los peers accedan a internet via este hub (opcional, comentar si no se desea)
|
||||
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
||||
EOF
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
_wg_hub_log "ERROR: no se pudo escribir ${config_path}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo chmod 600 "${config_path}" \
|
||||
|| { _wg_hub_log "ERROR: chmod 600 ${config_path} falló"; return 1; }
|
||||
_wg_hub_log "Permisos 600 aplicados a ${config_path}"
|
||||
|
||||
# ── Habilitar ip_forward persistente ────────────────────────────────────
|
||||
local sysctl_file="/etc/sysctl.d/99-wireguard.conf"
|
||||
if [[ ! -f "${sysctl_file}" ]] || ! grep -q "net.ipv4.ip_forward" "${sysctl_file}" 2>/dev/null; then
|
||||
_wg_hub_log "Habilitando ip_forward en ${sysctl_file}"
|
||||
echo "net.ipv4.ip_forward = 1" | sudo tee "${sysctl_file}" > /dev/null \
|
||||
|| { _wg_hub_log "ERROR: no se pudo escribir ${sysctl_file}"; return 1; }
|
||||
fi
|
||||
sudo sysctl -p "${sysctl_file}" >&2 \
|
||||
|| _wg_hub_log "WARN: sysctl -p falló (puede ignorarse si el kernel ya tiene ip_forward=1)"
|
||||
|
||||
# ── Abrir puerto en firewall ─────────────────────────────────────────────
|
||||
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
_wg_hub_log "ufw activo — abriendo UDP/${listen_port}"
|
||||
sudo ufw allow "${listen_port}/udp" >&2 \
|
||||
|| _wg_hub_log "WARN: ufw allow ${listen_port}/udp falló (verificar manualmente)"
|
||||
elif command -v iptables &>/dev/null; then
|
||||
_wg_hub_log "ufw inactivo — usando iptables para abrir UDP/${listen_port}"
|
||||
sudo iptables -C INPUT -p udp --dport "${listen_port}" -j ACCEPT 2>/dev/null \
|
||||
|| sudo iptables -A INPUT -p udp --dport "${listen_port}" -j ACCEPT >&2 \
|
||||
|| _wg_hub_log "WARN: iptables INPUT rule falló (verificar manualmente)"
|
||||
else
|
||||
_wg_hub_log "WARN: ni ufw ni iptables disponibles — abre el puerto ${listen_port}/udp manualmente"
|
||||
fi
|
||||
|
||||
# ── Detener interfaz si estaba corriendo (para aplicar nueva config) ────
|
||||
if sudo wg show "${interface}" &>/dev/null 2>&1; then
|
||||
_wg_hub_log "Interfaz ${interface} activa — deteniendo antes de reconfigurar"
|
||||
sudo systemctl stop "wg-quick@${interface}" 2>/dev/null \
|
||||
|| sudo wg-quick down "${interface}" 2>/dev/null \
|
||||
|| _wg_hub_log "WARN: no se pudo detener ${interface} (puede que no estuviera activa)"
|
||||
fi
|
||||
|
||||
# ── Habilitar y arrancar wg-quick@wg0 ────────────────────────────────────
|
||||
_wg_hub_log "Habilitando systemd unit wg-quick@${interface}"
|
||||
sudo systemctl enable "wg-quick@${interface}" >&2 \
|
||||
|| { _wg_hub_log "ERROR: systemctl enable wg-quick@${interface} falló"; return 1; }
|
||||
|
||||
_wg_hub_log "Arrancando wg-quick@${interface}"
|
||||
sudo systemctl start "wg-quick@${interface}" >&2 \
|
||||
|| { _wg_hub_log "ERROR: systemctl start wg-quick@${interface} falló"; return 1; }
|
||||
|
||||
# ── Verificar que la interfaz está UP ────────────────────────────────────
|
||||
local retries=5
|
||||
local up=0
|
||||
for (( i=0; i<retries; i++ )); do
|
||||
if sudo wg show "${interface}" &>/dev/null 2>&1; then
|
||||
up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${up}" -eq 0 ]]; then
|
||||
_wg_hub_log "ERROR: 'wg show ${interface}' falló tras ${retries}s — la interfaz no arrancó"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_hub_log "Interfaz ${interface} UP (status=${action_status})"
|
||||
printf '{"status":"%s","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
|
||||
"${action_status}" "${config_path}" "${interface}" "${hub_ip}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: wg_install
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_install() -> json"
|
||||
description: "Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch). Idempotente. Carga modulo kernel. Emite JSON con distro detectada y version instalada."
|
||||
tags: [wireguard, install, infra, mesh, deploy]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params: []
|
||||
output: "JSON {status, distro, version}. status=installed o already-present."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/wg_install.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/wg_install.sh
|
||||
wg_install
|
||||
# {"status":"installed","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
|
||||
|
||||
# Si ya está instalado:
|
||||
wg_install
|
||||
# {"status":"already-present","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites asegurarte de que wireguard-tools está disponible en un host antes de configurar un peer o hub WireGuard. Úsala como paso previo en pipelines de bootstrapping de nodos mesh (flow wireguard).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `sudo` con NOPASSWD para apt-get/dnf/pacman y para modprobe. El operador debe haberlo configurado antes.
|
||||
- `modprobe wireguard` puede fallar en kernels < 5.6 sin DKMS instalado (wireguard-dkms). La función lo trata como advertencia, no como error fatal — la instalación de las herramientas igual se completa.
|
||||
- En RHEL/CentOS instala `epel-release` automáticamente antes de wireguard-tools.
|
||||
- Distros no reconocidas en `/etc/os-release ID` producen exit 1 con mensaje de error explícito en stderr.
|
||||
- Los logs van siempre a stderr con prefijo `[wg_install]`; stdout es exclusivamente el JSON de resultado.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
<!-- Rellenar solo cuando haya version bump real -->
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_install — Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch).
|
||||
# Idempotente: si wg ya está instalado emite JSON con status=already-present y sale.
|
||||
# Carga módulo kernel wireguard. Emite JSON a stdout. Logs a stderr con prefijo [wg_install].
|
||||
# Exit 0 = éxito, 1 = fallo.
|
||||
|
||||
wg_install() {
|
||||
local distro="" version="" status=""
|
||||
|
||||
_wg_log() { echo "[wg_install] $*" >&2; }
|
||||
|
||||
# Detectar distro via /etc/os-release
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
distro=$(. /etc/os-release && echo "${ID:-unknown}")
|
||||
else
|
||||
_wg_log "ERROR: /etc/os-release no encontrado; no se puede detectar distro"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_wg_log "Distro detectada: ${distro}"
|
||||
|
||||
# Comprobar si wg ya está instalado (idempotencia)
|
||||
if command -v wg &>/dev/null; then
|
||||
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
_wg_log "wireguard-tools ya presente (${version}); cargando módulo kernel"
|
||||
# Intentar cargar módulo igualmente (no fatal)
|
||||
sudo modprobe wireguard 2>/dev/null || true
|
||||
printf '{"status":"already-present","distro":"%s","version":"%s"}\n' "${distro}" "${version}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Instalar según distro
|
||||
case "${distro}" in
|
||||
debian|ubuntu|linuxmint|pop|kali|raspbian)
|
||||
_wg_log "Usando apt-get (${distro})"
|
||||
sudo apt-get update -y >&2 || { _wg_log "ERROR: apt-get update falló"; return 1; }
|
||||
sudo apt-get install -y wireguard wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: apt-get install wireguard falló"; return 1; }
|
||||
;;
|
||||
fedora)
|
||||
_wg_log "Usando dnf (fedora)"
|
||||
sudo dnf install -y wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
rhel|centos|rocky|almalinux)
|
||||
_wg_log "Usando dnf (rhel/centos/rocky/alma)"
|
||||
sudo dnf install -y epel-release >&2 || true
|
||||
sudo dnf install -y wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
arch|manjaro|endeavouros)
|
||||
_wg_log "Usando pacman (arch)"
|
||||
sudo pacman -S --noconfirm wireguard-tools >&2 \
|
||||
|| { _wg_log "ERROR: pacman install wireguard-tools falló"; return 1; }
|
||||
;;
|
||||
*)
|
||||
_wg_log "ERROR: distro '${distro}' no soportada (soportadas: debian/ubuntu/fedora/rhel/arch)"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Verificar instalación
|
||||
if ! command -v wg &>/dev/null; then
|
||||
_wg_log "ERROR: 'wg' no encontrado tras la instalación"
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
_wg_log "wireguard-tools instalado: ${version}"
|
||||
|
||||
# Cargar módulo kernel (no fatal: kernels >=5.6 lo incluyen built-in)
|
||||
if sudo modprobe wireguard 2>/dev/null; then
|
||||
_wg_log "Módulo kernel wireguard cargado"
|
||||
else
|
||||
_wg_log "WARN: modprobe wireguard falló (puede estar built-in en el kernel o requerir DKMS)"
|
||||
fi
|
||||
|
||||
status="installed"
|
||||
printf '{"status":"%s","distro":"%s","version":"%s"}\n' "${status}" "${distro}" "${version}"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: wg_status
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "wg_status([interface_name]) -> json"
|
||||
description: "Parsea `wg show <iface> dump` a JSON estructurado con peers, handshake age, status (online/stale/never), bytes rx/tx. Resuelve device_id desde comentarios en wg0.conf. Para dashboards (agents_dashboard Mesh panel)."
|
||||
tags: [wireguard, status, observability, json, infra]
|
||||
params:
|
||||
- name: interface_name
|
||||
desc: "Nombre de la interface WireGuard (default wg0)"
|
||||
output: "JSON con interface info + array de peers. Cada peer incluye public_key, device_id (de comentario # DeviceID:<id> en wg0.conf), endpoint, allowed_ips, latest_handshake_unix, latest_handshake_ago_s, rx_bytes, tx_bytes, persistent_keepalive, status (online/stale/never)."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: true
|
||||
tests:
|
||||
- "interface con 2 peers online y stale"
|
||||
- "interface sin peers devuelve array vacio"
|
||||
- "interface inexistente devuelve error JSON"
|
||||
- "WG_FAKE_DUMP carga dump de archivo"
|
||||
test_file_path: "bash/functions/infra/wg_status_test.sh"
|
||||
file_path: "bash/functions/infra/wg_status.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Estado real de wg0
|
||||
source bash/functions/infra/wg_status.sh
|
||||
wg_status | jq .
|
||||
|
||||
# Interface distinta
|
||||
wg_status wg1 | jq .peers[].status
|
||||
|
||||
# Sin sudo real (testing / CI)
|
||||
WG_FAKE_DUMP=bash/functions/infra/wg_status_test_dump.tsv wg_status wg0 | jq .
|
||||
```
|
||||
|
||||
Salida representativa:
|
||||
|
||||
```json
|
||||
{
|
||||
"interface": "wg0",
|
||||
"public_key": "abcXYZ123...",
|
||||
"listen_port": "51820",
|
||||
"peers": [
|
||||
{
|
||||
"public_key": "peerKey1...",
|
||||
"device_id": "pc-aurgi",
|
||||
"endpoint": "1.2.3.4:54321",
|
||||
"allowed_ips": ["10.42.0.10/32"],
|
||||
"latest_handshake_unix": 1716000000,
|
||||
"latest_handshake_ago_s": 42,
|
||||
"rx_bytes": 12345,
|
||||
"tx_bytes": 67890,
|
||||
"persistent_keepalive": 25,
|
||||
"status": "online"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites saber el estado del mesh WireGuard desde un script, dashboard o agente. Usa antes de mostrar el panel Mesh en `agents_dashboard`. Llama cada N segundos para polling ligero desde shell sin depender de la API de WireGuard.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `CAP_NET_ADMIN` / root: `wg show` falla sin permisos. En produccion ejecutar via `sudo -n wg show wg0 dump` o dar permiso al binario. Para tests sin sudo: `WG_FAKE_DUMP=<path>` carga el dump desde archivo.
|
||||
- `listen_port` se devuelve como string (tal como lo emite `wg show dump`). El campo es `"0"` si wg no esta activo pero la interface existe.
|
||||
- `device_id` queda `""` si no hay comentario `# DeviceID:<id>` antes del `[Peer]` correspondiente en `/etc/wireguard/<iface>.conf`.
|
||||
- Status `stale` cubre desde 180s hasta cualquier valor mayor. No hay distincion entre "hace 5 min" y "hace 3 dias" — ambos son `stale`. Para un threshold mas fino, usar `latest_handshake_ago_s` directamente.
|
||||
- Si `/etc/wireguard/<iface>.conf` no existe o no es legible, `device_id` sera `""` para todos los peers (la funcion no falla, solo omite el lookup).
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# wg_status — Parsea `wg show <iface> dump` a JSON estructurado con peers,
|
||||
# handshake age, status (online/stale/never), bytes rx/tx.
|
||||
# Resuelve device_id desde comentarios # DeviceID:<id> en wg0.conf.
|
||||
#
|
||||
# Usage:
|
||||
# wg_status [interface_name] # default: wg0
|
||||
#
|
||||
# Env:
|
||||
# WG_FAKE_DUMP=<path> # lee dump de archivo en vez de llamar wg show (para tests)
|
||||
|
||||
wg_status() {
|
||||
local iface="${1:-wg0}"
|
||||
local conf="${WG_FAKE_CONF:-/etc/wireguard/${iface}.conf}"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
# --- obtener dump (real o fake) ---
|
||||
local dump
|
||||
if [[ -n "${WG_FAKE_DUMP:-}" ]]; then
|
||||
if [[ ! -f "$WG_FAKE_DUMP" ]]; then
|
||||
printf '{"error":"WG_FAKE_DUMP file not found: %s"}\n' "$WG_FAKE_DUMP"
|
||||
return 1
|
||||
fi
|
||||
dump=$(cat "$WG_FAKE_DUMP")
|
||||
else
|
||||
if ! command -v wg &>/dev/null; then
|
||||
printf '{"error":"wg command not found"}\n'
|
||||
return 1
|
||||
fi
|
||||
if ! dump=$(wg show "$iface" dump 2>&1); then
|
||||
if echo "$dump" | grep -qi "no such device\|does not exist\|unable to access interface"; then
|
||||
printf '{"error":"interface not found"}\n'
|
||||
return 1
|
||||
fi
|
||||
printf '{"error":"%s"}\n' "$(echo "$dump" | head -n1 | sed 's/"/\\"/g')"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- primera linea: info de la propia interface ---
|
||||
# formato: <private_key>\t<public_key>\t<listen_port>\t<fwmark>
|
||||
local iface_line
|
||||
iface_line=$(echo "$dump" | head -n1)
|
||||
|
||||
local iface_pubkey iface_port
|
||||
iface_pubkey=$(echo "$iface_line" | awk -F'\t' '{print $2}')
|
||||
iface_port=$(echo "$iface_line" | awk -F'\t' '{print $3}')
|
||||
|
||||
# --- leer DeviceID map desde wg0.conf ---
|
||||
# Busca patron:
|
||||
# # DeviceID:<id>
|
||||
# [Peer]
|
||||
# PublicKey = <pk>
|
||||
# Producimos pares "pk\tdevice_id" en un archivo temporal para lookup via awk
|
||||
local device_map
|
||||
device_map=$(awk '
|
||||
/^#[[:space:]]*DeviceID:/ {
|
||||
split($0, a, "DeviceID:")
|
||||
did = a[2]
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", did)
|
||||
pending_did = did
|
||||
}
|
||||
/^\[Peer\]/ {
|
||||
in_peer = 1
|
||||
}
|
||||
in_peer && /^PublicKey[[:space:]]*=/ {
|
||||
pk = $0
|
||||
sub(/^PublicKey[[:space:]]*=[[:space:]]*/, "", pk)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk)
|
||||
if (pending_did != "") {
|
||||
print pk "\t" pending_did
|
||||
pending_did = ""
|
||||
}
|
||||
in_peer = 0
|
||||
}
|
||||
' "$conf" 2>/dev/null)
|
||||
|
||||
# --- parsear peers (lineas 2..N del dump) ---
|
||||
# formato peer: <public_key>\t<preshared_key>\t<endpoint>\t<allowed_ips>\t<latest_handshake>\t<rx_bytes>\t<tx_bytes>\t<persistent_keepalive>
|
||||
local peers_json
|
||||
peers_json=$(echo "$dump" | tail -n +2 | awk -v now="$now" -v dmap="$device_map" '
|
||||
BEGIN {
|
||||
# construir lookup device_id
|
||||
n = split(dmap, lines, "\n")
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (lines[i] != "") {
|
||||
split(lines[i], parts, "\t")
|
||||
pk_to_did[parts[1]] = parts[2]
|
||||
}
|
||||
}
|
||||
first = 1
|
||||
printf "["
|
||||
}
|
||||
NF >= 7 {
|
||||
pk = $1
|
||||
endpoint = $3
|
||||
allowed = $4
|
||||
hs = $5 + 0
|
||||
rx = $6 + 0
|
||||
tx = $7 + 0
|
||||
ka = $8
|
||||
|
||||
# device_id lookup
|
||||
did = (pk in pk_to_did) ? pk_to_did[pk] : ""
|
||||
|
||||
# handshake age y status
|
||||
if (hs == 0) {
|
||||
ago = 0
|
||||
status = "never"
|
||||
} else {
|
||||
ago = now - hs
|
||||
if (ago < 180) status = "online"
|
||||
else if (ago < 86400) status = "stale"
|
||||
else status = "stale"
|
||||
}
|
||||
|
||||
# persistent_keepalive
|
||||
ka_val = (ka == "off" || ka == "") ? 0 : ka + 0
|
||||
|
||||
# endpoint null si "(none)"
|
||||
ep_val = (endpoint == "(none)") ? "null" : "\"" endpoint "\""
|
||||
|
||||
# allowed_ips array
|
||||
n_ips = split(allowed, ips_arr, ",")
|
||||
ips_json = "["
|
||||
for (j = 1; j <= n_ips; j++) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", ips_arr[j])
|
||||
ips_json = ips_json "\"" ips_arr[j] "\""
|
||||
if (j < n_ips) ips_json = ips_json ","
|
||||
}
|
||||
ips_json = ips_json "]"
|
||||
|
||||
if (!first) printf ","
|
||||
first = 0
|
||||
|
||||
printf "{"
|
||||
printf "\"public_key\":\"%s\"", pk
|
||||
printf ",\"device_id\":\"%s\"", did
|
||||
printf ",\"endpoint\":%s", ep_val
|
||||
printf ",\"allowed_ips\":%s", ips_json
|
||||
printf ",\"latest_handshake_unix\":%d", hs
|
||||
printf ",\"latest_handshake_ago_s\":%d",ago
|
||||
printf ",\"rx_bytes\":%d", rx
|
||||
printf ",\"tx_bytes\":%d", tx
|
||||
printf ",\"persistent_keepalive\":%d", ka_val
|
||||
printf ",\"status\":\"%s\"", status
|
||||
printf "}"
|
||||
}
|
||||
END { printf "]" }
|
||||
' FS='\t')
|
||||
|
||||
# --- output final ---
|
||||
printf '{"interface":"%s","public_key":"%s","listen_port":%s,"peers":%s}\n' \
|
||||
"$iface" "$iface_pubkey" "$iface_port" "$peers_json"
|
||||
}
|
||||
|
||||
# Permitir invocacion directa: bash wg_status.sh [iface]
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
wg_status "$@"
|
||||
fi
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests para wg_status
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/wg_status.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local test_name="$1" needle="$2" haystack="$3"
|
||||
if ! echo "$haystack" | grep -qF "$needle"; then
|
||||
echo "PASS: $test_name"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo "FAIL: $test_name — expected NOT to contain '$needle'"
|
||||
echo " got: $haystack"
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- fixtures ---
|
||||
FAKE_DUMP=$(mktemp)
|
||||
FAKE_DUMP_EMPTY=$(mktemp)
|
||||
FAKE_CONF=$(mktemp)
|
||||
trap 'rm -f "$FAKE_DUMP" "$FAKE_DUMP_EMPTY" "$FAKE_CONF"' EXIT
|
||||
|
||||
NOW=$(date +%s)
|
||||
HS_ONLINE=$(( NOW - 60 )) # 60s ago → online
|
||||
HS_STALE=$(( NOW - 500 )) # 500s ago → stale
|
||||
|
||||
# dump con 2 peers (tabs como separador)
|
||||
printf '%s\n' \
|
||||
"privKeyBase64== ifacePubKey== 51820 off" \
|
||||
"peerKey1== (none) 1.2.3.4:54321 10.42.0.10/32 ${HS_ONLINE} 12345 67890 25" \
|
||||
"peerKey2== (none) 5.6.7.8:12345 10.42.0.20/32 ${HS_STALE} 111 222 0" \
|
||||
> "$FAKE_DUMP"
|
||||
|
||||
# dump vacío (solo línea de interface, sin peers)
|
||||
printf '%s\n' "privKeyBase64== ifacePubKey== 51820 off" > "$FAKE_DUMP_EMPTY"
|
||||
|
||||
# conf con DeviceID comments
|
||||
cat > "$FAKE_CONF" <<'CONF'
|
||||
[Interface]
|
||||
PrivateKey = privKeyBase64==
|
||||
Address = 10.42.0.1/24
|
||||
ListenPort = 51820
|
||||
|
||||
# DeviceID:pc-aurgi
|
||||
[Peer]
|
||||
PublicKey = peerKey1==
|
||||
AllowedIPs = 10.42.0.10/32
|
||||
|
||||
# DeviceID:home-wsl
|
||||
[Peer]
|
||||
PublicKey = peerKey2==
|
||||
AllowedIPs = 10.42.0.20/32
|
||||
CONF
|
||||
|
||||
# --- Test: interface con 2 peers online y stale ---
|
||||
result=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "interface con 2 peers online y stale" '"interface":"wg0"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"listen_port":51820' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"public_key":"ifacePubKey=="' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"status":"online"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"status":"stale"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"device_id":"pc-aurgi"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"device_id":"home-wsl"' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"rx_bytes":12345' "$result"
|
||||
assert_contains "interface con 2 peers online y stale" '"persistent_keepalive":25' "$result"
|
||||
|
||||
# --- Test: interface sin peers devuelve array vacio ---
|
||||
result_empty=$(WG_FAKE_DUMP="$FAKE_DUMP_EMPTY" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "interface sin peers devuelve array vacio" '"peers":[]' "$result_empty"
|
||||
assert_not_contains "interface sin peers devuelve array vacio" '"error"' "$result_empty"
|
||||
|
||||
# --- Test: interface inexistente devuelve error JSON ---
|
||||
result_err=$(wg_status nonexistent_iface_xyz 2>/dev/null || true)
|
||||
assert_contains "interface inexistente devuelve error JSON" '"error"' "$result_err"
|
||||
|
||||
# --- Test: WG_FAKE_DUMP carga dump de archivo ---
|
||||
result_fake=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
|
||||
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"public_key":"ifacePubKey=="' "$result_fake"
|
||||
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"peers":[{' "$result_fake"
|
||||
|
||||
echo "---"
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -3,11 +3,11 @@ name: write_mcp_jupyter_config
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
|
||||
tags: [mcp, jupyter, config, setup, infra]
|
||||
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
|
||||
tags: [mcp, jupyter, config, setup, infra, notebook]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -30,10 +30,28 @@ file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
|
||||
|
||||
```bash
|
||||
source write_mcp_jupyter_config.sh
|
||||
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
|
||||
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
|
||||
echo "Config MCP en: $path"
|
||||
# Genera .mcp.json con:
|
||||
# "command": ".../.venv/bin/jupyter-mcp-server"
|
||||
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
|
||||
```
|
||||
|
||||
## Notas
|
||||
## Cuando usarla
|
||||
|
||||
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
|
||||
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
|
||||
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
|
||||
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
|
||||
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
|
||||
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
|
||||
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
|
||||
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
|
||||
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# write_mcp_jupyter_config
|
||||
# -------------------------
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||
# Usa el python del venv local con -m jupyter_mcp_server.server.
|
||||
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
|
||||
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
|
||||
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
|
||||
# Hace merge si ya existe .mcp.json (requiere jq).
|
||||
#
|
||||
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
|
||||
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
|
||||
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
|
||||
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
|
||||
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
|
||||
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
|
||||
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_mcp_jupyter_config.sh
|
||||
# write_mcp_jupyter_config /path/to/project 8888
|
||||
@@ -17,14 +25,15 @@ write_mcp_jupyter_config() {
|
||||
abs_project="$(cd "$project_dir" && pwd)"
|
||||
|
||||
local python_bin="${abs_project}/.venv/bin/python"
|
||||
local mcp_bin="${abs_project}/.venv/bin/jupyter-mcp-server"
|
||||
if [ ! -f "$python_bin" ]; then
|
||||
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar que el modulo esta instalado
|
||||
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
|
||||
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
|
||||
# Verificar que el console-script esta instalado
|
||||
if [ ! -x "$mcp_bin" ]; then
|
||||
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -33,12 +42,12 @@ write_mcp_jupyter_config() {
|
||||
{
|
||||
"mcpServers": {
|
||||
"jupyter": {
|
||||
"command": "${python_bin}",
|
||||
"args": ["-m", "jupyter_mcp_server.server"],
|
||||
"env": {
|
||||
"SERVER_URL": "http://localhost:${port}",
|
||||
"TOKEN": ""
|
||||
}
|
||||
"command": "${mcp_bin}",
|
||||
"args": [
|
||||
"--transport", "stdio",
|
||||
"--jupyter-url", "http://localhost:${port}",
|
||||
"--jupyter-token", ""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,8 +55,10 @@ EOF
|
||||
)
|
||||
|
||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||
# Merge conservando otros servidores MCP
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
|
||||
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
|
||||
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
|
||||
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||
mv "${mcp_file}.tmp" "$mcp_file"
|
||||
else
|
||||
|
||||
@@ -47,7 +47,7 @@ file_path: "bash/functions/pipelines/agent_scaffold.sh"
|
||||
|
||||
```bash
|
||||
# Crear agente basico con openai
|
||||
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
|
||||
export FN_REGISTRY_ROOT=$HOME/fn_registry
|
||||
bash bash/functions/pipelines/agent_scaffold.sh monitor-bot \
|
||||
--display-name "Monitor Agent" \
|
||||
--description "Monitorea servicios y reporta estado" \
|
||||
|
||||
@@ -30,14 +30,14 @@ file_path: "bash/functions/pipelines/backup_all.sh"
|
||||
|
||||
```bash
|
||||
# Backup manual a ~/backups/fn_registry
|
||||
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
|
||||
export FN_REGISTRY_ROOT=$HOME/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
|
||||
# 0 2 * * * FN_REGISTRY_ROOT=$HOME/fn_registry bash $HOME/fn_registry/bash/functions/pipelines/backup_all.sh ~/backups/fn_registry
|
||||
```
|
||||
|
||||
## Estructura de backup_root/
|
||||
|
||||
@@ -43,7 +43,7 @@ file_path: "bash/functions/pipelines/clone_project_subrepos.sh"
|
||||
# analysis domain_coverage_gaps [cloned]
|
||||
#
|
||||
# Siguiente paso sugerido:
|
||||
# cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index && ./fn sync
|
||||
# cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index && ./fn sync
|
||||
|
||||
# Con owner alternativo
|
||||
./fn run clone_project_subrepos aurgi --owner miorg
|
||||
@@ -65,7 +65,7 @@ Cuando llegas a un PC nuevo con solo fn_registry clonado y quieres trabajar en u
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
|
||||
- `GITEA_URL` — URL base de Gitea; default `https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com`
|
||||
- Auth git/ssh: el pipeline confía en la config local del usuario (SSH key, credential helper)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ clone_project_subrepos() {
|
||||
fi
|
||||
|
||||
# --- Resolver paths ---
|
||||
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||
local db="$registry_root/registry.db"
|
||||
local gitea_url="${GITEA_URL:-https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com}"
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ output: "Compila el .exe y lo despliega al escritorio de Windows. Imprime progre
|
||||
|
||||
```bash
|
||||
# Desde dentro del directorio de la app (sin arg)
|
||||
cd /home/lucas/fn_registry/cpp/apps/chart_demo
|
||||
cd $HOME/fn_registry/cpp/apps/chart_demo
|
||||
fn run compile_cpp_app
|
||||
|
||||
# Con nombre explicito desde cualquier directorio
|
||||
@@ -51,7 +51,7 @@ bash bash/functions/pipelines/compile_cpp_app.sh graph_explorer
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/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`
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ compile_cpp_app() {
|
||||
deploy_cpp_exe_to_windows "$APP" "$APP_DIR"
|
||||
|
||||
# --- Resumen final ---
|
||||
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
local root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: compile_wails_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "compile_wails_app(app_name_or_empty: string) -> void"
|
||||
description: "Pipeline que resuelve la app Wails desde el nombre o CWD, la compila para Windows con wails build -platform windows/amd64 (detectando -tags goolm automaticamente si la app usa E2EE Matrix), y despliega el .exe al escritorio de Windows + relanza el proceso. Equivalente a compile_cpp_app pero para apps Wails (Go + WebView2)."
|
||||
tags: [wails, windows, compile, pipelines, launch, matrix-mas]
|
||||
uses_functions:
|
||||
- resolve_cpp_app_dir_bash_infra
|
||||
- deploy_wails_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_wails_app.sh"
|
||||
params:
|
||||
- name: app_name_or_empty
|
||||
desc: "Nombre de la app Wails a compilar (opcional). Sin arg se deduce desde el directorio actual si estamos dentro de projects/*/apps/<X>/ o apps/<X>/. Lista apps disponibles si no puede deducirlo."
|
||||
output: "Compila el .exe con wails build, lo despliega al escritorio de Windows y relanza el proceso. Imprime progreso por steps a stderr y resumen final con ls -lh del .exe resultante."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Desde el directorio de la app (deduce nombre automaticamente)
|
||||
cd projects/element_agents/apps/matrix_client_pc
|
||||
./fn run compile_wails_app
|
||||
|
||||
# Desde la raiz del registry, con nombre explicito
|
||||
cd $HOME/fn_registry
|
||||
./fn run compile_wails_app matrix_admin_panel
|
||||
|
||||
# Directo sin fn run
|
||||
bash bash/functions/pipelines/compile_wails_app.sh matrix_client_pc
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usar cuando quieras rebuild + redeploy + relanzar una app Wails con un solo comando durante iteracion activa de desarrollo. Equivale al slash command `/compile` aplicado a targets Wails. El pipeline detecta automaticamente si la app necesita `-tags goolm` (apps Matrix con E2EE).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Requiere `wails` CLI instalado en PATH y mingw-w64 configurado para cross-compile (`GOARCH=amd64 GOOS=windows` via toolchain Wails).
|
||||
- Si la app usa `-tags goolm` (E2EE Matrix), esta pipeline lo detecta automaticamente: busca `matrix_crypto_init` en `app.md` o `"build:tags": "goolm"` en `wails.json`. Si la deteccion falla, pasar la variable `TAGS` o editar el `wails.json`.
|
||||
- El relanzar despues del deploy es la diferencia clave con `compile_cpp_app`: las apps Wails son single-binary (no DLLs adicionales) y arrancan en <1s, lo que hace iteracion muy rapida.
|
||||
- Si el build falla con `no required module provides package`, ejecutar `go mod tidy` en el directorio de la app antes de volver a compilar.
|
||||
- `matrix_client_pc` tiene helpers en `internal/infra/` que son copias vendored de `functions/infra/` del registry padre. Si actualizas un helper en el registry padre, debes copiarlo manualmente a la app antes de compilar — el build de Wails no ve el modulo padre.
|
||||
- El deploy mata el proceso anterior con `taskkill.exe /F` (pre-autorizado) antes de copiar el .exe, para evitar "Permission denied" de Windows al sobreescribir un binario en uso.
|
||||
- Variable de entorno `WIN_DESKTOP_APPS` controla el destino; default `/mnt/c/Users/lucas/Desktop/apps`.
|
||||
|
||||
## Flujo
|
||||
|
||||
1. `resolve_cpp_app_dir` — deduce nombre y directorio absoluto de la app (desde CWD o arg)
|
||||
2. Verifica `wails.json` y `go.mod` en el directorio de la app
|
||||
3. Detecta si necesita `-tags goolm` (app.md referencia `matrix_crypto_init` o wails.json lo declara)
|
||||
4. `wails build -platform windows/amd64 [tags]` desde el directorio de la app
|
||||
5. `deploy_wails_exe_to_windows` — mata proceso, copia .exe, relanza y verifica PID
|
||||
6. Imprime `ls -lh` del exe final en `Desktop/apps/<APP>/`
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pipeline: compile_wails_app — Resuelve la app Wails desde el nombre o CWD,
|
||||
# la compila para Windows con wails build y despliega al escritorio + relanza.
|
||||
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/deploy_wails_exe_to_windows.sh"
|
||||
|
||||
compile_wails_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 es una app Wails (no C++) ---
|
||||
if [ ! -f "$APP_DIR/wails.json" ]; then
|
||||
echo "ERROR: $APP_DIR/wails.json no encontrado." >&2
|
||||
echo "La app '$APP' no es una app Wails." >&2
|
||||
echo "Si es C++, usa compile_cpp_app en su lugar." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$APP_DIR/go.mod" ]; then
|
||||
echo "ERROR: $APP_DIR/go.mod no encontrado." >&2
|
||||
echo "Una app Wails requiere go.mod. Ejecuta 'go mod init' en $APP_DIR." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Paso 2: Compilar para Windows con wails ---
|
||||
echo "" >&2
|
||||
echo "[2/3] Compilando '$APP' para Windows (wails + mingw)..." >&2
|
||||
|
||||
# Detectar si necesita -tags goolm:
|
||||
# 1. app.md declara matrix_crypto_init en uses_functions (E2EE habilitado)
|
||||
# 2. wails.json tiene "build:tags": "goolm" (o "buildTags": "goolm")
|
||||
local TAGS=""
|
||||
local app_md="${APP_DIR}/app.md"
|
||||
local wails_json="${APP_DIR}/wails.json"
|
||||
local needs_goolm=0
|
||||
|
||||
if [ -f "$app_md" ] && grep -q "matrix_crypto_init" "$app_md" 2>/dev/null; then
|
||||
needs_goolm=1
|
||||
echo " Detectado matrix_crypto_init en app.md -> usando -tags goolm" >&2
|
||||
fi
|
||||
|
||||
if [ "$needs_goolm" -eq 0 ] && [ -f "$wails_json" ]; then
|
||||
if grep -qE '"(build:tags|buildTags)"\s*:\s*"goolm"' "$wails_json" 2>/dev/null; then
|
||||
needs_goolm=1
|
||||
echo " Detectado goolm en wails.json -> usando -tags goolm" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$needs_goolm" -eq 1 ]; then
|
||||
TAGS="-tags goolm"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$APP_DIR"
|
||||
# shellcheck disable=SC2086
|
||||
wails build -platform windows/amd64 $TAGS
|
||||
)
|
||||
|
||||
# --- Paso 3: Desplegar al escritorio + relanzar ---
|
||||
echo "" >&2
|
||||
echo "[3/3] Desplegando '$APP' al escritorio + relanzar..." >&2
|
||||
deploy_wails_exe_to_windows "$APP" "$APP_DIR"
|
||||
|
||||
# --- Resumen final ---
|
||||
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_wails_app: OK =====" >&2
|
||||
ls -lh "$final_exe" >&2
|
||||
else
|
||||
echo "WARN: no se encuentra $final_exe" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
compile_wails_app "${1:-}"
|
||||
@@ -63,7 +63,7 @@ file_path: "bash/functions/pipelines/dockerize_app.sh"
|
||||
|
||||
```bash
|
||||
# Deploy completo con basicAuth
|
||||
cd /home/lucas/fn_registry
|
||||
cd $HOME/fn_registry
|
||||
bash bash/functions/pipelines/dockerize_app.sh kanban \
|
||||
--domain kanban.organic-machine.com \
|
||||
--port 8421 \
|
||||
|
||||
@@ -46,7 +46,7 @@ bash bash/functions/pipelines/full_git_pull.sh
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
|
||||
- `FN_REGISTRY_API`, `REGISTRY_API_TOKEN` — se cargan de `pass registry/*`
|
||||
|
||||
## Notas
|
||||
|
||||
@@ -12,8 +12,9 @@ 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}"
|
||||
# Resolver raiz del registry. Deriva de SCRIPT_DIR (bash/functions/pipelines/)
|
||||
# para funcionar en cualquier PC sin path hardcodeado.
|
||||
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
cd "$registry_root"
|
||||
|
||||
echo "=== full_git_pull: inicio ===" >&2
|
||||
|
||||
@@ -55,7 +55,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
|
||||
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/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/*`
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ 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}"
|
||||
# Resolver raiz del registry. Deriva de SCRIPT_DIR (bash/functions/pipelines/)
|
||||
# para funcionar en cualquier PC sin path hardcodeado.
|
||||
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
cd "$registry_root"
|
||||
|
||||
echo "=== full_git_push: inicio ===" >&2
|
||||
@@ -52,8 +53,15 @@ full_git_push() {
|
||||
[[ -z "$dir_path" ]] && continue
|
||||
local d="$registry_root/$dir_path"
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -d "$d/.git" ]] && continue
|
||||
echo " auto-init: $d" >&2
|
||||
# Skip solo si ya tiene .git CON remote origin. Un .git sin origin
|
||||
# (init local que nunca llego a crear repo Gitea) cae a push step y
|
||||
# falla con "'origin' does not appear to be a git repository".
|
||||
if [[ -d "$d/.git" ]]; then
|
||||
git -C "$d" remote get-url origin >/dev/null 2>&1 && continue
|
||||
echo " fix-remote: $d (.git sin origin)" >&2
|
||||
else
|
||||
echo " auto-init: $d" >&2
|
||||
fi
|
||||
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
|
||||
echo " [warn] fallo inicializando $d" >&2
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
|
||||
@@ -67,6 +75,25 @@ full_git_push() {
|
||||
# Redescubrir repos tras posibles inicializaciones
|
||||
repos=$(discover_git_repos "$registry_root")
|
||||
|
||||
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
|
||||
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
|
||||
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
|
||||
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
|
||||
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
|
||||
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
|
||||
local claude_repo=""
|
||||
if [[ -L "$HOME/.claude/settings.json" ]]; then
|
||||
local _claude_settings_real
|
||||
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
|
||||
if [[ -n "$_claude_settings_real" ]]; then
|
||||
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
|
||||
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
|
||||
repos="$repos"$'\n'"$claude_repo"
|
||||
fi
|
||||
|
||||
# --- Paso 2: Escanear secrets ---
|
||||
echo "" >&2
|
||||
echo "[2/6] Escaneando secrets en dirty trees..." >&2
|
||||
|
||||
@@ -34,11 +34,11 @@ file_path: "bash/functions/pipelines/generate_capability_doc.sh"
|
||||
```bash
|
||||
# Regenerar tabla de notebook (ya existe, preserva Ejemplo canonico / Fronteras)
|
||||
./bash/functions/pipelines/generate_capability_doc.sh notebook
|
||||
# → /home/lucas/fn_registry/docs/capabilities/notebook.md updated (5 functions)
|
||||
# → $HOME/fn_registry/docs/capabilities/notebook.md updated (5 functions)
|
||||
|
||||
# Crear pagina nueva para un grupo sin pagina todavia
|
||||
./bash/functions/pipelines/generate_capability_doc.sh metabase
|
||||
# → /home/lucas/fn_registry/docs/capabilities/metabase.md created (12 functions)
|
||||
# → $HOME/fn_registry/docs/capabilities/metabase.md created (12 functions)
|
||||
|
||||
# Especificar registry y destino custom
|
||||
./bash/functions/pipelines/generate_capability_doc.sh android \
|
||||
@@ -49,7 +49,7 @@ file_path: "bash/functions/pipelines/generate_capability_doc.sh"
|
||||
# Grupo sin funciones todavia (avisa pero no falla)
|
||||
./bash/functions/pipelines/generate_capability_doc.sh nuevo_grupo
|
||||
# WARN: El grupo 'nuevo_grupo' no tiene funciones con ese tag en registry.db.
|
||||
# → /home/lucas/fn_registry/docs/capabilities/nuevo_grupo.md created (0 functions)
|
||||
# → $HOME/fn_registry/docs/capabilities/nuevo_grupo.md created (0 functions)
|
||||
```
|
||||
|
||||
## Comportamiento detallado
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
|
||||
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/keepass_dump.sh"
|
||||
|
||||
@@ -27,7 +27,7 @@ params:
|
||||
- name: app_name
|
||||
desc: "Nombre de la app C++ (ej: chart_demo, registry_dashboard). Se usa para localizar el .exe en cpp/build/windows/apps/<app>/ y el 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). Requerido para localizar enrichers/, runtime/ y app.md."
|
||||
desc: "Ruta absoluta al directorio fuente de la app (ej: $HOME/fn_registry/cpp/apps/chart_demo). Requerido para localizar enrichers/, runtime/ y app.md."
|
||||
- name: "--build"
|
||||
desc: "Flag opcional. Si presente, compila la app para Windows antes del deploy. Por defecto off (asume .exe ya compilado)."
|
||||
output: "Imprime 'OK: <app_name> redeployed (build=yes/no, PID=N)' en stdout. Exit 1 en cualquier paso fallido con mensaje de error indicando el paso."
|
||||
@@ -37,10 +37,10 @@ output: "Imprime 'OK: <app_name> redeployed (build=yes/no, PID=N)' en stdout. Ex
|
||||
|
||||
```bash
|
||||
# Solo redeploy (asume build ya hecho)
|
||||
redeploy_cpp_app_windows "registry_dashboard" "/home/lucas/fn_registry/projects/fn_monitoring/apps/registry_dashboard"
|
||||
redeploy_cpp_app_windows "registry_dashboard" "$HOME/fn_registry/projects/fn_monitoring/apps/registry_dashboard"
|
||||
|
||||
# Con build previo
|
||||
redeploy_cpp_app_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo" --build
|
||||
redeploy_cpp_app_windows "chart_demo" "$HOME/fn_registry/cpp/apps/chart_demo" --build
|
||||
```
|
||||
|
||||
## Comportamiento
|
||||
|
||||
@@ -20,7 +20,7 @@ error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: registry_db_path
|
||||
desc: "ruta a registry.db local (default: /home/lucas/fn_registry/registry.db)"
|
||||
desc: "ruta a registry.db local (default: $HOME/fn_registry/registry.db)"
|
||||
- name: container_name
|
||||
desc: "nombre del contenedor Metabase (default: metabase)"
|
||||
- name: dest_path
|
||||
@@ -40,7 +40,7 @@ file_path: "bash/functions/pipelines/setup_metabase_volume.sh"
|
||||
|
||||
# Con argumentos explícitos
|
||||
./functions/pipelines/setup_metabase_volume.sh \
|
||||
/home/lucas/fn_registry/registry.db \
|
||||
$HOME/fn_registry/registry.db \
|
||||
metabase \
|
||||
/registry.db
|
||||
```
|
||||
@@ -59,7 +59,7 @@ El pipeline usa `set -euo pipefail` — cualquier fallo en una función individu
|
||||
Las funciones individuales se sourcean desde sus rutas en el registry, relativas a `REGISTRY_ROOT` detectado automáticamente desde la ubicación del script.
|
||||
|
||||
Defaults:
|
||||
- `REGISTRY_DB_PATH`: `/home/lucas/fn_registry/registry.db`
|
||||
- `REGISTRY_DB_PATH`: `$HOME/fn_registry/registry.db`
|
||||
- `CONTAINER_NAME`: `metabase`
|
||||
- `DEST_PATH`: `/registry.db`
|
||||
|
||||
@@ -67,7 +67,7 @@ Nota de persistencia: `docker cp` copia al contenedor en ejecución. Si el conte
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /home/lucas/fn_registry:/fn_registry:ro
|
||||
- $HOME/fn_registry:/fn_registry:ro
|
||||
```
|
||||
|
||||
Y usar `--registry-db-path /fn_registry/registry.db`.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# ARGUMENTOS (opcionales, con defaults):
|
||||
# REGISTRY_DB_PATH Ruta local al registry.db
|
||||
# Default: /home/lucas/fn_registry/registry.db
|
||||
# Default: <raiz_del_registry>/registry.db
|
||||
# CONTAINER_NAME Nombre del contenedor Docker de Metabase
|
||||
# Default: metabase
|
||||
# DEST_PATH Ruta destino dentro del contenedor
|
||||
@@ -26,7 +26,7 @@ source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/assert_docker_container_running.sh"
|
||||
source "$REGISTRY_ROOT/bash/functions/infra/docker_cp_file.sh"
|
||||
|
||||
REGISTRY_DB_PATH="${1:-/home/lucas/fn_registry/registry.db}"
|
||||
REGISTRY_DB_PATH="${1:-$REGISTRY_ROOT/registry.db}"
|
||||
CONTAINER_NAME="${2:-metabase}"
|
||||
DEST_PATH="${3:-/registry.db}"
|
||||
|
||||
|
||||
@@ -44,11 +44,11 @@ file_path: "bash/functions/pipelines/vault_audit.sh"
|
||||
|
||||
```bash
|
||||
# Auditar un vault especifico
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry \
|
||||
bash bash/functions/pipelines/vault_audit.sh turismo_spain
|
||||
|
||||
# Auditar todos los vaults
|
||||
FN_REGISTRY_ROOT=/home/lucas/fn_registry \
|
||||
FN_REGISTRY_ROOT=$HOME/fn_registry \
|
||||
bash bash/functions/pipelines/vault_audit.sh --all
|
||||
|
||||
# Solo layout + index + aggregate (sin profilers, mas rapido)
|
||||
|
||||
@@ -29,7 +29,7 @@ file_path: "bash/functions/shell/assert_file_exists.sh"
|
||||
```bash
|
||||
source functions/shell/assert_file_exists.sh
|
||||
|
||||
size=$(assert_file_exists /home/lucas/fn_registry/registry.db)
|
||||
size=$(assert_file_exists $HOME/fn_registry/registry.db)
|
||||
echo "Tamaño: $size bytes"
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ file_path: "bash/functions/shell/validate_registry_paths.sh"
|
||||
|
||||
```bash
|
||||
source validate_registry_paths.sh
|
||||
validate_registry_paths /home/lucas/fn_registry/registry.db functions /home/lucas/fn_registry
|
||||
validate_registry_paths $HOME/fn_registry/registry.db functions $HOME/fn_registry
|
||||
|
||||
# Output (TSV):
|
||||
# cdp_click_go_browser functions/infra/cdp_click.go browser functions
|
||||
|
||||
@@ -535,3 +535,27 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag
|
||||
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
||||
endif()
|
||||
|
||||
# --- kanban_cpp (lives in apps/, issue 0096) ---
|
||||
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
|
||||
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
|
||||
endif()
|
||||
|
||||
# --- data_table_bench (lives in apps/, issue 0133) ---
|
||||
# Requires SQLite3 dev libs. Skip silently when not available (e.g. cross-windows build).
|
||||
set(_DATA_TABLE_BENCH_DIR ${CMAKE_SOURCE_DIR}/../apps/data_table_bench)
|
||||
if(EXISTS ${_DATA_TABLE_BENCH_DIR}/CMakeLists.txt)
|
||||
find_package(SQLite3 QUIET)
|
||||
if(SQLite3_FOUND)
|
||||
add_subdirectory(${_DATA_TABLE_BENCH_DIR} ${CMAKE_BINARY_DIR}/apps/data_table_bench)
|
||||
else()
|
||||
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- image_to_3d_studio (lives in projects/imagegen/apps/) ---
|
||||
set(_IMAGE_TO_3D_STUDIO_DIR ${CMAKE_SOURCE_DIR}/../projects/imagegen/apps/image_to_3d_studio)
|
||||
if(EXISTS ${_IMAGE_TO_3D_STUDIO_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_IMAGE_TO_3D_STUDIO_DIR} ${CMAKE_BINARY_DIR}/apps/image_to_3d_studio)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Paleta xterm-16 en ABGR (little-endian: R,G,B,A en memoria = RGBA8888 en lectura).
|
||||
// Index 0-7 colores normales, 8-15 brillantes, 16 = default.
|
||||
const uint32_t kPalette16[17] = {
|
||||
0xFF000000, // 0 black
|
||||
0xFF0000AA, // 1 red
|
||||
0xFF00AA00, // 2 green
|
||||
0xFF00AAAA, // 3 yellow (dark)
|
||||
0xFFAA0000, // 4 blue
|
||||
0xFFAA00AA, // 5 magenta
|
||||
0xFFAAAA00, // 6 cyan
|
||||
0xFFAAAAAA, // 7 white (light grey)
|
||||
0xFF555555, // 8 bright black (dark grey)
|
||||
0xFF5555FF, // 9 bright red
|
||||
0xFF55FF55, // 10 bright green
|
||||
0xFF55FFFF, // 11 bright yellow
|
||||
0xFFFF5555, // 12 bright blue
|
||||
0xFFFF55FF, // 13 bright magenta
|
||||
0xFFFFFF55, // 14 bright cyan
|
||||
0xFFFFFFFF, // 15 bright white
|
||||
0xFFCCCCCC, // 16 default (light grey)
|
||||
};
|
||||
|
||||
AnsiParser::AnsiParser() {
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::reset() {
|
||||
state_ = State::Ground;
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
process_byte(static_cast<unsigned char>(data[i]), cb);
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::flush_param() {
|
||||
if (param_count_ < kMaxParams) {
|
||||
params_[param_count_++] = cur_param_;
|
||||
}
|
||||
cur_param_ = 0;
|
||||
}
|
||||
|
||||
void AnsiParser::apply_sgr(const std::function<void(const AnsiEvent&)>& /*cb*/) {
|
||||
// Si no hay params → reset (SGR 0).
|
||||
int n = (param_count_ == 0) ? 1 : param_count_;
|
||||
const int* p = (param_count_ == 0) ? nullptr : params_;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int code = (p ? p[i] : 0);
|
||||
if (code == 0) {
|
||||
// Reset todo
|
||||
cur_fg_ = kColorDefault;
|
||||
cur_bg_ = kColorDefault;
|
||||
cur_bold_ = 0;
|
||||
} else if (code == 1) {
|
||||
cur_bold_ = 1;
|
||||
} else if (code == 22) {
|
||||
cur_bold_ = 0;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 30);
|
||||
} else if (code == 39) {
|
||||
cur_fg_ = kColorDefault;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 40);
|
||||
} else if (code == 49) {
|
||||
cur_bg_ = kColorDefault;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 90 + 8);
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 100 + 8);
|
||||
}
|
||||
// Otros códigos ignorados silenciosamente (v1 anti-scope).
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
AnsiEvent ev;
|
||||
int p0 = (param_count_ > 0) ? params_[0] : 0;
|
||||
int p1 = (param_count_ > 1) ? params_[1] : 0;
|
||||
|
||||
switch (final_byte) {
|
||||
case 'H': case 'f': {
|
||||
// CUP: ESC [ row ; col H (1-based → convertir a 0-based)
|
||||
ev.type = AnsiEventType::CursorAbsolute;
|
||||
ev.cursor_abs.row = (p0 > 0 ? p0 - 1 : 0);
|
||||
ev.cursor_abs.col = (p1 > 0 ? p1 - 1 : 0);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'A': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Up;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'B': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Down;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'C': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Forward;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'D': {
|
||||
ev.type = AnsiEventType::CursorMove;
|
||||
ev.cursor_rel.dir = CursorDir::Back;
|
||||
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
|
||||
cb(ev);
|
||||
break;
|
||||
}
|
||||
case 'J': {
|
||||
// ED: erase in display. Solo param=2 (clear screen) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseDisplay;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'K': {
|
||||
// EL: erase in line. Solo param=2 (clear entire line) soportado en v1.
|
||||
if (p0 == 2 || p0 == 0) {
|
||||
ev.type = AnsiEventType::EraseLine;
|
||||
cb(ev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'm': {
|
||||
// SGR: select graphic rendition.
|
||||
apply_sgr(cb);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Secuencia CSI desconocida — ignorar silenciosamente.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb) {
|
||||
switch (state_) {
|
||||
|
||||
case State::Ground:
|
||||
if (c == 0x1B) {
|
||||
state_ = State::Escape;
|
||||
} else if (c == '\r') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::CarriageReturn; cb(ev);
|
||||
} else if (c == '\n') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Newline; cb(ev);
|
||||
} else if (c == '\x08') {
|
||||
AnsiEvent ev; ev.type = AnsiEventType::Backspace; cb(ev);
|
||||
} else if (c >= 0x20 && c < 0x7F) {
|
||||
// ASCII imprimible.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = static_cast<char32_t>(c);
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0xC0) {
|
||||
// Inicio de secuencia UTF-8 multi-byte.
|
||||
// En v1 mapeamos todo >= 0x80 a '?' para evitar complejidad Unicode.
|
||||
// TODO(0132): soporte Unicode completo en v2.
|
||||
AnsiEvent ev;
|
||||
ev.type = AnsiEventType::Char;
|
||||
ev.cell.ch = U'?';
|
||||
ev.cell.fg = cur_fg_;
|
||||
ev.cell.bg = cur_bg_;
|
||||
ev.cell.bold = cur_bold_;
|
||||
cb(ev);
|
||||
} else if (c >= 0x80 && c < 0xC0) {
|
||||
// Continuation byte de UTF-8 → ignorar (fragmento de multi-byte).
|
||||
}
|
||||
// Otros control bytes (0x00-0x1F excl \r\n\x08\x1B) → ignorar.
|
||||
break;
|
||||
|
||||
case State::Escape:
|
||||
if (c == '[') {
|
||||
state_ = State::CsiEntry;
|
||||
param_count_ = 0;
|
||||
cur_param_ = 0;
|
||||
} else {
|
||||
// Secuencia ESC desconocida (no-CSI) → volver a Ground.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiEntry:
|
||||
// Primer byte del CSI: puede ser un dígito, ';' o el final byte.
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = c - '0';
|
||||
state_ = State::CsiParam;
|
||||
} else if (c == ';') {
|
||||
// Parámetro vacío → valor 0.
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
state_ = State::CsiParam;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final inmediato sin parámetros.
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else if (c == '?') {
|
||||
// Modos privados (e.g. ESC[?25l cursor hide) → ignorar hasta final byte.
|
||||
// Permanecemos en CsiEntry esperando el final byte.
|
||||
} else {
|
||||
// Byte inesperado → abortar CSI.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
|
||||
case State::CsiParam:
|
||||
if (c >= '0' && c <= '9') {
|
||||
cur_param_ = cur_param_ * 10 + (c - '0');
|
||||
} else if (c == ';') {
|
||||
flush_param();
|
||||
cur_param_ = 0;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
// Byte final: flush último param y despachar.
|
||||
flush_param();
|
||||
dispatch_csi(c, cb);
|
||||
state_ = State::Ground;
|
||||
} else {
|
||||
// Byte inesperado → abortar.
|
||||
state_ = State::Ground;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
// ansi_parser — parser ANSI/VT100 minimo, byte-a-byte, sin heap allocs por evento.
|
||||
//
|
||||
// Soporta:
|
||||
// SGR: colores FG/BG 16 colores (30-37, 40-47, 90-97, 100-107), bold (1), reset (0).
|
||||
// CUP (H): cursor absolute position row,col.
|
||||
// CUU (A), CUD (B), CUF (C), CUB (D): cursor relative moves.
|
||||
// ED (J): erase in display (param=2 → clear screen).
|
||||
// EL (K): erase in line (param=2 → clear line).
|
||||
// Carriage Return (\r), Newline (\n), Backspace (\x08).
|
||||
// Text: caracteres imprimibles (excl. control bytes).
|
||||
//
|
||||
// No soportado (v1, anti-scope):
|
||||
// 256/24-bit color, italics, underline, Unicode wide, OSC, DCS, SOS, PM, APC,
|
||||
// CSI sequences > 16 parametros, character sets (SI/SO), private modes.
|
||||
//
|
||||
// Uso:
|
||||
// fn_term::AnsiParser p;
|
||||
// p.feed(data, n, [](const fn_term::AnsiEvent& ev) { /* handle */ });
|
||||
//
|
||||
// Thread-safety: NO. Cada instancia debe usarse desde un solo hilo.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Codigos de color ANSI → index 0-15 en paleta CGA/xterm-16.
|
||||
// 0-7: colores normales (black, red, green, yellow, blue, magenta, cyan, white)
|
||||
// 8-15: colores brillantes (idem + bright)
|
||||
// 16: color por defecto (FG o BG)
|
||||
static constexpr uint8_t kColorDefault = 16;
|
||||
|
||||
// Paleta xterm-16 en RGBA8888 (A=0xFF), misma que la mayoria de terminales.
|
||||
// Acceso: kPalette16[index], index in [0,15].
|
||||
extern const uint32_t kPalette16[17]; // [16] = color "default" (blanco/negro)
|
||||
|
||||
// Una celda del terminal virtual.
|
||||
struct AnsiCell {
|
||||
char32_t ch = U' '; // codepoint Unicode (solo BMP en v1)
|
||||
uint8_t fg = kColorDefault; // indice paleta 0-16 (16 = default)
|
||||
uint8_t bg = kColorDefault;
|
||||
uint8_t bold = 0;
|
||||
uint8_t _pad = 0;
|
||||
};
|
||||
|
||||
// Tipos de evento emitidos por el parser.
|
||||
enum class AnsiEventType : uint8_t {
|
||||
Char, // un caracter imprimible (AnsiEvent.cell.ch valido)
|
||||
CursorMove, // AnsiEvent.row / .col delta o absoluto segun subtype
|
||||
CursorAbsolute, // CUP: posicion absoluta 0-based (row, col)
|
||||
EraseDisplay, // ED(2): limpiar pantalla completa
|
||||
EraseLine, // EL(2): limpiar linea actual completa
|
||||
CarriageReturn, // \r
|
||||
Newline, // \n
|
||||
Backspace, // \x08
|
||||
};
|
||||
|
||||
// Subtipos de CursorMove.
|
||||
enum class CursorDir : uint8_t { Up, Down, Forward, Back };
|
||||
|
||||
struct AnsiEvent {
|
||||
AnsiEventType type;
|
||||
union {
|
||||
AnsiCell cell; // type == Char
|
||||
struct {
|
||||
CursorDir dir;
|
||||
int n; // pasos (>= 1)
|
||||
} cursor_rel; // type == CursorMove
|
||||
struct {
|
||||
int row; // 0-based
|
||||
int col; // 0-based
|
||||
} cursor_abs; // type == CursorAbsolute
|
||||
// EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace: sin datos extra.
|
||||
};
|
||||
|
||||
AnsiEvent() : type(AnsiEventType::Char), cell{} {}
|
||||
};
|
||||
|
||||
// Clase principal. Stateful — mantiene el estado del parser entre llamadas a feed().
|
||||
class AnsiParser {
|
||||
public:
|
||||
AnsiParser();
|
||||
~AnsiParser() = default;
|
||||
AnsiParser(const AnsiParser&) = delete;
|
||||
AnsiParser& operator=(const AnsiParser&) = delete;
|
||||
|
||||
// Procesa `n` bytes de `data`. Emite eventos via `cb` en orden.
|
||||
// cb puede ser llamada 0 o más veces por feed().
|
||||
// Sin alloc heap por byte ni por evento.
|
||||
void feed(const char* data, size_t n,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
|
||||
// Resetea el estado del parser (útil al limpiar pantalla).
|
||||
void reset();
|
||||
|
||||
// Atributos SGR actuales (se actualizan al procesar secuencias SGR).
|
||||
uint8_t current_fg() const { return cur_fg_; }
|
||||
uint8_t current_bg() const { return cur_bg_; }
|
||||
uint8_t current_bold() const { return cur_bold_; }
|
||||
|
||||
private:
|
||||
enum class State : uint8_t {
|
||||
Ground, // estado normal: procesar texto
|
||||
Escape, // recibido ESC
|
||||
CsiEntry, // recibido ESC [
|
||||
CsiParam, // acumulando parametros CSI
|
||||
};
|
||||
|
||||
State state_ = State::Ground;
|
||||
uint8_t cur_fg_ = kColorDefault;
|
||||
uint8_t cur_bg_ = kColorDefault;
|
||||
uint8_t cur_bold_ = 0;
|
||||
|
||||
// Buffer de parametros CSI (max 16 params de 4 digitos cada uno).
|
||||
static constexpr int kMaxParams = 16;
|
||||
int params_[kMaxParams];
|
||||
int param_count_ = 0;
|
||||
int cur_param_ = 0; // valor del param que se esta acumulando
|
||||
|
||||
void process_byte(unsigned char c,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void flush_param();
|
||||
void dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void apply_sgr(const std::function<void(const AnsiEvent&)>& cb);
|
||||
};
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: ansi_parser
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "class fn_term::AnsiParser { void feed(const char* data, size_t n, const std::function<void(const fn_term::AnsiEvent&)>& cb); void reset(); uint8_t current_fg() const; uint8_t current_bg() const; uint8_t current_bold() const; }"
|
||||
description: "Parser ANSI/VT100 minimo byte-a-byte sin alloc heap por evento. Soporta SGR colores FG/BG 16-color + bold + reset, cursor moves (CUP/CUU/CUD/CUF/CUB), erase display/line (ED 2, EL 2), CR/LF/BS. Statemachine simple con 4 estados. Emite AnsiEvent via callback."
|
||||
tags: [ansi, vt100, terminal, parser, pure, state-machine, cpp-dashboard-viz]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [cstddef, cstdint, functional]
|
||||
tested: true
|
||||
tests:
|
||||
- "SGR reset sets default colors"
|
||||
- "SGR fg color 31 sets red"
|
||||
- "SGR bg color 44 sets blue background"
|
||||
- "SGR bright fg 91 sets bright red"
|
||||
- "SGR bold sets bold flag"
|
||||
- "cursor CUU moves up N"
|
||||
- "cursor CUF moves forward N"
|
||||
- "cursor CUP absolute position"
|
||||
- "erase display ED 2"
|
||||
- "erase line EL 2"
|
||||
- "mixed text and SGR sequence"
|
||||
- "newline and carriage return"
|
||||
test_file_path: "cpp/tests/test_ansi_parser.cpp"
|
||||
file_path: "cpp/functions/core/ansi_parser.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: data
|
||||
desc: "Puntero al buffer de bytes a procesar (output crudo de PTY/ConPTY)"
|
||||
- name: n
|
||||
desc: "Numero de bytes en data"
|
||||
- name: cb
|
||||
desc: "Callback invocado por cada evento emitido. Sin alloc — el AnsiEvent vive en el stack del parser"
|
||||
output: "Sin retorno directo. Eventos emitidos via callback: AnsiEventType::Char (caracter + atributos SGR actuales), CursorMove (relativo), CursorAbsolute (CUP), EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace"
|
||||
notes: "Usado por terminal_panel_cpp_viz como paso de parseo del output PTY. Anti-scope v1: sin 256/24-bit color, sin italics/underline, sin Unicode wide, sin OSC/DCS. UTF-8 multi-byte se mapea a '?' en v1."
|
||||
---
|
||||
|
||||
# ansi_parser
|
||||
|
||||
Parser ANSI/VT100 minimo para el modulo `terminal_panel`. Sin heap allocs por byte procesado — la maquina de estados vive en el objeto y los `AnsiEvent` se emiten por callback en el stack del caller.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
fn_term::AnsiParser parser;
|
||||
std::string output;
|
||||
|
||||
// Procesar output crudo de PTY:
|
||||
parser.feed(pty_buf, bytes_read, [&](const fn_term::AnsiEvent& ev) {
|
||||
if (ev.type == fn_term::AnsiEventType::Char) {
|
||||
// ev.cell.ch = codepoint, ev.cell.fg = color index 0-16
|
||||
output += static_cast<char>(ev.cell.ch);
|
||||
} else if (ev.type == fn_term::AnsiEventType::Newline) {
|
||||
output += '\n';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando procesas output crudo de un PTY (Linux forkpty) o ConPTY (Windows) y necesitas extraer texto + atributos de color para renderizar en ImGui con `PushStyleColor`. Es la capa de parseo de `terminal_panel`.
|
||||
|
||||
## Secuencias soportadas (v1)
|
||||
|
||||
| Tipo | Secuencia | AnsiEventType |
|
||||
|------|-----------|---------------|
|
||||
| Texto ASCII | bytes 0x20-0x7E | Char |
|
||||
| CR | `\r` (0x0D) | CarriageReturn |
|
||||
| LF | `\n` (0x0A) | Newline |
|
||||
| BS | `\x08` | Backspace |
|
||||
| SGR reset | `ESC[0m` o `ESC[m` | (actualiza estado interno) |
|
||||
| SGR bold | `ESC[1m` | (actualiza estado interno) |
|
||||
| SGR FG 16 | `ESC[30-37m`, `ESC[90-97m` | (actualiza estado interno) |
|
||||
| SGR BG 16 | `ESC[40-47m`, `ESC[100-107m` | (actualiza estado interno) |
|
||||
| Cursor UP | `ESC[nA` | CursorMove (Up, n) |
|
||||
| Cursor DOWN | `ESC[nB` | CursorMove (Down, n) |
|
||||
| Cursor FWD | `ESC[nC` | CursorMove (Forward, n) |
|
||||
| Cursor BACK | `ESC[nD` | CursorMove (Back, n) |
|
||||
| CUP | `ESC[r;cH` | CursorAbsolute (0-based) |
|
||||
| ED(2) | `ESC[2J` | EraseDisplay |
|
||||
| EL(2) | `ESC[2K` | EraseLine |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Anti-scope v1: no 256-color (`ESC[38;5;Nm`), no 24-bit color, no italics/underline, no curses pesados.
|
||||
- UTF-8 multi-byte: bytes de continuacion 0x80-0xBF ignorados; inicio 0xC0+ emite `?`. Soporte completo en v2.
|
||||
- No thread-safe: cada instancia debe usarse desde un solo hilo (el reader thread del PTY).
|
||||
- `kPalette16[16]` es el color "default" (gris claro). El caller decide si usar el color del tema o la paleta fija.
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "compute_column_stats.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -353,6 +355,59 @@ struct VizPanel {
|
||||
mutable ViewMode last_non_table = ViewMode::Bar;
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// StringPool — interning de strings para columnas de texto (issue 0133).
|
||||
// Una instancia por State (NOT global) para aislar tablas independientes.
|
||||
//
|
||||
// intern(sv) devuelve un indice uint32_t estable para la vida del rebuild.
|
||||
// El pool se limpia (clear()) al inicio de cada rebuild de snapshot columnar.
|
||||
//
|
||||
// Invariante de invalidacion de string_view:
|
||||
// - El vector `strings` se reserva con reserve() ANTES del primer intern()
|
||||
// para evitar reallocs que invalidarian los string_view del mapa.
|
||||
// Si la estimacion es insuficiente (columna con mas unicos de lo esperado),
|
||||
// el mapa se reconstruye post-push_back: intern() verifica cap antes de
|
||||
// insertar en el map para cubrir este caso.
|
||||
// ----------------------------------------------------------------------------
|
||||
struct StringPool {
|
||||
std::vector<std::string> strings; // strings unicos, por indice
|
||||
std::unordered_map<std::string_view, uint32_t> index; // sv→id (sv apunta a strings[i])
|
||||
|
||||
void clear() {
|
||||
strings.clear();
|
||||
index.clear();
|
||||
}
|
||||
|
||||
// intern: inserta si no existe. Devuelve indice estable.
|
||||
// INVARIANTE: reserve() ANTES del primer intern() por columna para evitar
|
||||
// reallocs que invalidarian los string_view del mapa. Si la estimacion fue
|
||||
// insuficiente, forzamos reserve(size+1) ANTES de emplace_back para que
|
||||
// la realloc ocurra antes de que cualquier sv del mapa apunte al buffer
|
||||
// viejo — y reconstruimos el mapa desde cero tras la realloc.
|
||||
uint32_t intern(std::string_view sv) {
|
||||
auto it = index.find(sv);
|
||||
if (it != index.end()) return it->second;
|
||||
uint32_t id = (uint32_t)strings.size();
|
||||
if (strings.size() == strings.capacity()) {
|
||||
// Realloc inminente: hacerlo ANTES de insertar en index para que
|
||||
// los string_view existentes no queden dangling. Tras el reserve,
|
||||
// reconstruimos el index desde cero porque los punteros cambiaron.
|
||||
strings.reserve(strings.capacity() == 0 ? 64 : strings.capacity() * 2);
|
||||
index.clear();
|
||||
for (uint32_t i = 0; i < (uint32_t)strings.size(); ++i)
|
||||
index.emplace(std::string_view(strings[i]), i);
|
||||
}
|
||||
strings.emplace_back(sv);
|
||||
// string_view apunta al almacenamiento interno (strings[id]), estable
|
||||
// porque acabamos de garantizar capacidad suficiente.
|
||||
index.emplace(std::string_view(strings[id]), id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const std::string& at(uint32_t id) const { return strings[id]; }
|
||||
bool empty() const { return strings.empty(); }
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// State: stage pipeline + viz globales.
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -419,6 +474,11 @@ struct State {
|
||||
std::vector<DrillStep> drill_back;
|
||||
std::vector<DrillStep> drill_forward;
|
||||
|
||||
// String interning pool (issue 0133, Change 2).
|
||||
// Limpiado y repoblado en cada rebuild del snapshot columnar.
|
||||
// NOT global — una instancia por State para aislar tablas independientes.
|
||||
StringPool string_pool;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
|
||||
@@ -269,8 +269,21 @@ Response request(const Request& req) {
|
||||
}
|
||||
|
||||
cmd << ' ' << sh_q(req.url)
|
||||
<< " -o " << sh_q(tmp_body_out)
|
||||
<< " 2>&1";
|
||||
<< " -o " << sh_q(tmp_body_out);
|
||||
|
||||
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
|
||||
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
|
||||
// extra positional arg to curl, which treats it as a second URL → "Bad
|
||||
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
|
||||
#ifndef _WIN32
|
||||
cmd << " 2>&1";
|
||||
#endif
|
||||
|
||||
if (std::getenv("FN_HTTP_DEBUG")) {
|
||||
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
|
||||
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
|
||||
req.url.c_str(), req.url.size());
|
||||
}
|
||||
|
||||
// Capture stderr (curl prints transport errors to stderr with -sS).
|
||||
std::string curl_stderr;
|
||||
|
||||
@@ -19,7 +19,7 @@ example: |
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::ifstream f("$HOME/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::stringstream ss; ss << f.rdbuf();
|
||||
auto fm = fn_md::parse_md_frontmatter(ss.str());
|
||||
auto title = std::get<std::string>(fm.fields["title"]);
|
||||
@@ -65,7 +65,7 @@ Cuando una app C++ necesita leer metadata de archivos Markdown del registry (iss
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::ifstream f("$HOME/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
|
||||
std::stringstream ss; ss << f.rdbuf();
|
||||
auto fm = fn_md::parse_md_frontmatter(ss.str());
|
||||
|
||||
|
||||
@@ -14,9 +14,17 @@ static void create_tex(Framebuffer& f) {
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
}
|
||||
|
||||
static void create_depth_rbo(Framebuffer& f) {
|
||||
glGenRenderbuffers(1, &f.depth_rbo);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_init(Framebuffer& f) {
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.has_depth = false;
|
||||
create_tex(f);
|
||||
glGenFramebuffers(1, &f.fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
@@ -24,23 +32,50 @@ void fb_init(Framebuffer& f) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_init_depth(Framebuffer& f) {
|
||||
f.width = 1;
|
||||
f.height = 1;
|
||||
f.has_depth = true;
|
||||
create_tex(f);
|
||||
create_depth_rbo(f);
|
||||
glGenFramebuffers(1, &f.fbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_resize(Framebuffer& f, int w, int h) {
|
||||
if (w == f.width && h == f.height) return;
|
||||
f.width = w;
|
||||
f.width = w;
|
||||
f.height = h;
|
||||
|
||||
// Recreate color texture.
|
||||
if (f.tex) glDeleteTextures(1, &f.tex);
|
||||
f.tex = 0;
|
||||
create_tex(f);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
|
||||
|
||||
// Resize depth renderbuffer in-place (no need to recreate).
|
||||
if (f.has_depth && f.depth_rbo) {
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||
// Re-attach in case it was lost (should be stable across storage resize, but be safe).
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
|
||||
}
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void fb_destroy(Framebuffer& f) {
|
||||
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
||||
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
||||
f.width = 0;
|
||||
f.height = 0;
|
||||
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
|
||||
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
|
||||
if (f.depth_rbo) { glDeleteRenderbuffers(1, &f.depth_rbo); f.depth_rbo = 0; }
|
||||
f.width = 0;
|
||||
f.height = 0;
|
||||
f.has_depth = false;
|
||||
}
|
||||
|
||||
} // namespace fn::gfx
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace fn::gfx {
|
||||
|
||||
struct Framebuffer {
|
||||
unsigned int fbo = 0;
|
||||
unsigned int tex = 0; // GL_RGBA8, clamp, linear
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
unsigned int fbo = 0;
|
||||
unsigned int tex = 0; // GL_RGBA8 color
|
||||
unsigned int depth_rbo = 0; // GL_DEPTH_COMPONENT24 renderbuffer, 0 si sin depth
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
bool has_depth = false;
|
||||
};
|
||||
|
||||
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 iniciales
|
||||
void fb_resize(Framebuffer& f, int w, int h); // no-op si w,h iguales
|
||||
void fb_destroy(Framebuffer& f);
|
||||
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 (color-only, retro-compat)
|
||||
void fb_init_depth(Framebuffer& f); // crea fbo+tex+depth_rbo 1x1
|
||||
void fb_resize(Framebuffer& f, int w, int h); // redimensiona color y depth (si has_depth); no-op si iguales
|
||||
void fb_destroy(Framebuffer& f); // libera fbo, tex y depth_rbo si existen
|
||||
|
||||
} // namespace fn::gfx
|
||||
|
||||
@@ -3,11 +3,11 @@ name: gl_framebuffer
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "void fb_init(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
||||
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8). fb_resize es no-op si las dimensiones no cambian. Listo para uso con ImGui::Image."
|
||||
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen]
|
||||
signature: "void fb_init(Framebuffer& f); void fb_init_depth(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
|
||||
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8, opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24). fb_init es color-only (retro-compat); fb_init_depth añade depth. fb_resize redimensiona color y depth si has_depth. Listo para uso con ImGui::Image."
|
||||
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen, depth, cpp-dashboard-viz]
|
||||
uses_functions: ["gl_loader_cpp_gfx"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -21,23 +21,23 @@ file_path: "cpp/functions/gfx/gl_framebuffer.cpp"
|
||||
framework: opengl
|
||||
params:
|
||||
- name: f
|
||||
desc: "Struct Framebuffer con campos fbo, tex (GL ids), width, height. Inicializar a {0} antes de fb_init."
|
||||
desc: "Struct Framebuffer con campos fbo, tex, depth_rbo (GL ids), width, height, has_depth. Inicializar a {0} antes de fb_init/fb_init_depth."
|
||||
- name: w
|
||||
desc: "Ancho deseado en pixels (fb_resize)"
|
||||
- name: h
|
||||
desc: "Alto deseado en pixels (fb_resize)"
|
||||
output: "Modifica f in-place. Después de fb_init, f.fbo y f.tex son IDs GL válidos. fb_destroy pone todos los campos a 0."
|
||||
output: "Modifica f in-place. Después de fb_init/fb_init_depth, f.fbo y f.tex son IDs GL válidos. Si fb_init_depth: f.depth_rbo != 0 y f.has_depth == true. fb_destroy pone todos los campos a 0."
|
||||
---
|
||||
|
||||
# gl_framebuffer
|
||||
|
||||
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
||||
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24. Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
|
||||
|
||||
## Ciclo de vida
|
||||
## Ciclo de vida — color-only (retro-compat)
|
||||
|
||||
```cpp
|
||||
fn::gfx::Framebuffer fb{};
|
||||
fn::gfx::fb_init(fb); // fbo + tex 1x1
|
||||
fn::gfx::fb_init(fb); // fbo + tex 1x1, has_depth=false
|
||||
|
||||
// En el render loop:
|
||||
fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
||||
@@ -46,6 +46,23 @@ fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
|
||||
fn::gfx::fb_destroy(fb);
|
||||
```
|
||||
|
||||
## Ciclo de vida — con depth renderbuffer
|
||||
|
||||
```cpp
|
||||
fn::gfx::Framebuffer fb{};
|
||||
fn::gfx::fb_init_depth(fb); // fbo + tex 1x1 + depth_rbo 1x1, has_depth=true
|
||||
|
||||
// En el render loop (antes de glDrawElements):
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDepthFunc(GL_LESS);
|
||||
|
||||
fn::gfx::fb_resize(fb, w, h); // redimensiona color Y depth_rbo
|
||||
|
||||
// Al destruir:
|
||||
fn::gfx::fb_destroy(fb); // libera fbo, tex y depth_rbo
|
||||
```
|
||||
|
||||
## Uso con ImGui::Image
|
||||
|
||||
```cpp
|
||||
@@ -59,4 +76,10 @@ ImGui::Image(
|
||||
|
||||
## Notas
|
||||
|
||||
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Esto minimiza el overhead de resize.
|
||||
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Para el depth renderbuffer, llama `glRenderbufferStorage` in-place (sin recrear el RBO). Esto minimiza el overhead de resize.
|
||||
|
||||
`fb_init` (sin depth) se mantiene idéntico al comportamiento pre-v1.1.0 — no rompe consumidores existentes (`shader_canvas`, `graph_renderer`).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-05-28) — fb_init_depth opcional + depth en fb_resize/fb_destroy
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
#include "gfx/gltf_load_mesh.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// nlohmann/json vendored
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace fn::gfx {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-local last error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static thread_local char s_last_error[512] = "";
|
||||
|
||||
static void set_error(const char* msg) {
|
||||
std::strncpy(s_last_error, msg, sizeof(s_last_error) - 1);
|
||||
s_last_error[sizeof(s_last_error) - 1] = '\0';
|
||||
}
|
||||
|
||||
const char* gltf_load_last_error() { return s_last_error; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GLB binary format constants (spec glTF 2.0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static constexpr uint32_t GLB_MAGIC = 0x46546C67u; // "glTF"
|
||||
static constexpr uint32_t GLB_VERSION = 2u;
|
||||
static constexpr uint32_t CHUNK_JSON = 0x4E4F534Au; // "JSON"
|
||||
static constexpr uint32_t CHUNK_BIN = 0x004E4942u; // "BIN\0"
|
||||
|
||||
// glTF accessor componentType
|
||||
static constexpr int CT_UNSIGNED_BYTE = 5121;
|
||||
static constexpr int CT_UNSIGNED_SHORT = 5123;
|
||||
static constexpr int CT_UNSIGNED_INT = 5125;
|
||||
static constexpr int CT_FLOAT = 5126;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void cross3(const float a[3], const float b[3], float out[3]) {
|
||||
out[0] = a[1]*b[2] - a[2]*b[1];
|
||||
out[1] = a[2]*b[0] - a[0]*b[2];
|
||||
out[2] = a[0]*b[1] - a[1]*b[0];
|
||||
}
|
||||
|
||||
static float dot3(const float a[3], const float b[3]) {
|
||||
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
||||
}
|
||||
|
||||
static float len3(const float a[3]) {
|
||||
return std::sqrt(dot3(a, a));
|
||||
}
|
||||
|
||||
// Multiply 4x4 column-major matrix by vec3 (point, w=1)
|
||||
static void mat4_mul_point(const float m[16], const float p[3], float out[3]) {
|
||||
out[0] = m[0]*p[0] + m[4]*p[1] + m[8] *p[2] + m[12];
|
||||
out[1] = m[1]*p[0] + m[5]*p[1] + m[9] *p[2] + m[13];
|
||||
out[2] = m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14];
|
||||
}
|
||||
|
||||
// Multiply 4x4 column-major matrix by vec3 (direction, w=0 — for normals use
|
||||
// inverse-transpose, which here is computed at call site)
|
||||
static void mat4_mul_dir(const float m[16], const float v[3], float out[3]) {
|
||||
out[0] = m[0]*v[0] + m[4]*v[1] + m[8] *v[2];
|
||||
out[1] = m[1]*v[0] + m[5]*v[1] + m[9] *v[2];
|
||||
out[2] = m[2]*v[0] + m[6]*v[1] + m[10]*v[2];
|
||||
}
|
||||
|
||||
// 3x3 inverse-transpose (for normal transform) extracted from upper-left of 4x4.
|
||||
// Returns false if matrix is singular (scale 0).
|
||||
static bool compute_normal_matrix(const float m[16], float out[9]) {
|
||||
// Extract upper-left 3x3 (column-major from 4x4)
|
||||
float a00=m[0], a10=m[1], a20=m[2];
|
||||
float a01=m[4], a11=m[5], a21=m[6];
|
||||
float a02=m[8], a12=m[9], a22=m[10];
|
||||
|
||||
float det = a00*(a11*a22 - a21*a12)
|
||||
- a01*(a10*a22 - a20*a12)
|
||||
+ a02*(a10*a21 - a20*a11);
|
||||
if (std::fabs(det) < 1e-12f) return false;
|
||||
float inv = 1.0f / det;
|
||||
|
||||
// Inverse of 3x3, then transpose → inverse-transpose columns become rows
|
||||
out[0] = inv * (a11*a22 - a21*a12);
|
||||
out[1] = inv * (a21*a02 - a01*a22);
|
||||
out[2] = inv * (a01*a12 - a11*a02);
|
||||
out[3] = inv * (a20*a12 - a10*a22);
|
||||
out[4] = inv * (a00*a22 - a20*a02);
|
||||
out[5] = inv * (a10*a02 - a00*a12);
|
||||
out[6] = inv * (a10*a21 - a20*a11);
|
||||
out[7] = inv * (a20*a01 - a00*a21);
|
||||
out[8] = inv * (a00*a11 - a10*a01);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void nrm3x3_mul(const float m[9], const float v[3], float out[3]) {
|
||||
out[0] = m[0]*v[0] + m[3]*v[1] + m[6]*v[2];
|
||||
out[1] = m[1]*v[0] + m[4]*v[1] + m[7]*v[2];
|
||||
out[2] = m[2]*v[0] + m[5]*v[1] + m[8]*v[2];
|
||||
}
|
||||
|
||||
// TRS → column-major 4x4 matrix
|
||||
// translation=[tx,ty,tz], rotation quaternion=[qx,qy,qz,qw], scale=[sx,sy,sz]
|
||||
static void trs_to_mat4(const float t[3], const float q[4], const float s[3],
|
||||
float out[16]) {
|
||||
float qx=q[0], qy=q[1], qz=q[2], qw=q[3];
|
||||
float x2=qx+qx, y2=qy+qy, z2=qz+qz;
|
||||
float xx=qx*x2, xy=qx*y2, xz=qx*z2;
|
||||
float yy=qy*y2, yz=qy*z2, zz=qz*z2;
|
||||
float wx=qw*x2, wy=qw*y2, wz=qw*z2;
|
||||
|
||||
out[0] = (1-(yy+zz))*s[0]; out[1] = (xy+wz)*s[0]; out[2] = (xz-wy)*s[0]; out[3] = 0;
|
||||
out[4] = (xy-wz)*s[1]; out[5] = (1-(xx+zz))*s[1]; out[6] = (yz+wx)*s[1]; out[7] = 0;
|
||||
out[8] = (xz+wy)*s[2]; out[9] = (yz-wx)*s[2]; out[10] = (1-(xx+yy))*s[2]; out[11] = 0;
|
||||
out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; out[15] = 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessor reading helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct BufView {
|
||||
const uint8_t* base = nullptr;
|
||||
size_t total = 0;
|
||||
};
|
||||
|
||||
// Read a single element of 'count' components from accessor at element index 'idx'.
|
||||
// component_type: CT_FLOAT, CT_UNSIGNED_BYTE, CT_UNSIGNED_SHORT, CT_UNSIGNED_INT
|
||||
// components_per_element: 1 (SCALAR) or 3 (VEC3) etc.
|
||||
// Returns false if out-of-bounds.
|
||||
static bool read_float_vec(const BufView& bin,
|
||||
int component_type,
|
||||
int components_per_element,
|
||||
size_t byte_offset, // accessor.byteOffset + bufferView.byteOffset
|
||||
int byte_stride, // bufferView.byteStride (0 = tightly packed)
|
||||
size_t idx,
|
||||
float out[4]) {
|
||||
size_t comp_size = 0;
|
||||
switch (component_type) {
|
||||
case CT_UNSIGNED_BYTE: comp_size = 1; break;
|
||||
case CT_UNSIGNED_SHORT: comp_size = 2; break;
|
||||
case CT_UNSIGNED_INT: comp_size = 4; break;
|
||||
case CT_FLOAT: comp_size = 4; break;
|
||||
default: return false;
|
||||
}
|
||||
size_t element_size = comp_size * (size_t)components_per_element;
|
||||
size_t stride = (byte_stride > 0) ? (size_t)byte_stride : element_size;
|
||||
size_t off = byte_offset + idx * stride;
|
||||
if (off + element_size > bin.total) return false;
|
||||
|
||||
const uint8_t* p = bin.base + off;
|
||||
for (int c = 0; c < components_per_element; ++c) {
|
||||
const uint8_t* cp = p + (size_t)c * comp_size;
|
||||
switch (component_type) {
|
||||
case CT_UNSIGNED_BYTE: out[c] = (float)*cp; break;
|
||||
case CT_UNSIGNED_SHORT: {
|
||||
uint16_t v; std::memcpy(&v, cp, 2); out[c] = (float)v; break;
|
||||
}
|
||||
case CT_UNSIGNED_INT: {
|
||||
uint32_t v; std::memcpy(&v, cp, 4); out[c] = (float)v; break;
|
||||
}
|
||||
case CT_FLOAT: {
|
||||
float v; std::memcpy(&v, cp, 4); out[c] = v; break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool read_index(const BufView& bin,
|
||||
int component_type,
|
||||
size_t byte_offset,
|
||||
size_t idx,
|
||||
uint32_t& out) {
|
||||
float v[1] = {};
|
||||
if (!read_float_vec(bin, component_type, 1, byte_offset, 0, idx, v))
|
||||
return false;
|
||||
out = static_cast<uint32_t>(v[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core GLB parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static Mesh parse_glb(const uint8_t* data, size_t size) {
|
||||
s_last_error[0] = '\0';
|
||||
|
||||
// --- 1. Validate header (12 bytes) ---
|
||||
if (size < 12) { set_error("file too small for GLB header"); return {}; }
|
||||
|
||||
uint32_t magic, version, total_len;
|
||||
std::memcpy(&magic, data, 4);
|
||||
std::memcpy(&version, data + 4, 4);
|
||||
std::memcpy(&total_len, data + 8, 4);
|
||||
|
||||
if (magic != GLB_MAGIC) { set_error("not a GLB file (bad magic)"); return {}; }
|
||||
if (version != GLB_VERSION){ set_error("unsupported GLB version (expected 2)"); return {}; }
|
||||
if (total_len > size) { set_error("GLB total_length > buffer size"); return {}; }
|
||||
|
||||
// --- 2. Walk chunks ---
|
||||
const uint8_t* json_data = nullptr; size_t json_len = 0;
|
||||
const uint8_t* bin_data = nullptr; size_t bin_len = 0;
|
||||
|
||||
size_t pos = 12;
|
||||
while (pos + 8 <= total_len) {
|
||||
uint32_t chunk_len, chunk_type;
|
||||
std::memcpy(&chunk_len, data + pos, 4);
|
||||
std::memcpy(&chunk_type, data + pos + 4, 4);
|
||||
pos += 8;
|
||||
if (pos + chunk_len > total_len) { set_error("chunk extends past file end"); return {}; }
|
||||
if (chunk_type == CHUNK_JSON) {
|
||||
json_data = data + pos;
|
||||
json_len = chunk_len;
|
||||
} else if (chunk_type == CHUNK_BIN) {
|
||||
bin_data = data + pos;
|
||||
bin_len = chunk_len;
|
||||
}
|
||||
pos += chunk_len;
|
||||
}
|
||||
|
||||
if (!json_data) { set_error("no JSON chunk found"); return {}; }
|
||||
|
||||
// --- 3. Parse JSON ---
|
||||
nlohmann::json j;
|
||||
try {
|
||||
j = nlohmann::json::parse(json_data, json_data + json_len);
|
||||
} catch (const std::exception& e) {
|
||||
std::snprintf(s_last_error, sizeof(s_last_error), "JSON parse error: %s", e.what());
|
||||
return {};
|
||||
}
|
||||
|
||||
// --- 4. Find first mesh / first primitive ---
|
||||
if (!j.contains("meshes") || j["meshes"].empty()) {
|
||||
set_error("no meshes in glTF");
|
||||
return {};
|
||||
}
|
||||
auto& prim = j["meshes"][0]["primitives"][0];
|
||||
|
||||
auto& attrs = prim["attributes"];
|
||||
if (!attrs.contains("POSITION")) {
|
||||
set_error("primitive has no POSITION attribute");
|
||||
return {};
|
||||
}
|
||||
|
||||
auto& accessors = j["accessors"];
|
||||
auto& bufferViews = j["bufferViews"];
|
||||
|
||||
BufView bin_view { bin_data, bin_len };
|
||||
|
||||
// Helper: resolve accessor index → (byte_offset, byte_stride, component_type, count, components_per_elem)
|
||||
struct AccInfo { size_t byte_offset; int byte_stride; int comp_type; size_t count; int ncomp; };
|
||||
|
||||
auto resolve_accessor = [&](int acc_idx, AccInfo& out) -> bool {
|
||||
if (acc_idx < 0 || acc_idx >= (int)accessors.size()) return false;
|
||||
auto& acc = accessors[acc_idx];
|
||||
int bv_idx = acc.value("bufferView", -1);
|
||||
size_t acc_offset = acc.value("byteOffset", 0);
|
||||
out.comp_type = acc.value("componentType", 0);
|
||||
out.count = acc.value("count", 0u);
|
||||
std::string type_str = acc.value("type", "SCALAR");
|
||||
out.ncomp = 1;
|
||||
if (type_str == "VEC2") out.ncomp = 2;
|
||||
else if (type_str == "VEC3") out.ncomp = 3;
|
||||
else if (type_str == "VEC4") out.ncomp = 4;
|
||||
|
||||
if (bv_idx >= 0 && bv_idx < (int)bufferViews.size()) {
|
||||
auto& bv = bufferViews[bv_idx];
|
||||
size_t bv_offset = bv.value("byteOffset", 0u);
|
||||
out.byte_stride = bv.value("byteStride", 0);
|
||||
out.byte_offset = acc_offset + bv_offset;
|
||||
} else {
|
||||
out.byte_offset = acc_offset;
|
||||
out.byte_stride = 0;
|
||||
}
|
||||
return out.count > 0 && out.comp_type != 0;
|
||||
};
|
||||
|
||||
// --- 5. Read POSITION ---
|
||||
AccInfo pos_info{};
|
||||
if (!resolve_accessor(attrs["POSITION"].get<int>(), pos_info)) {
|
||||
set_error("failed to resolve POSITION accessor");
|
||||
return {};
|
||||
}
|
||||
if (pos_info.ncomp != 3 || pos_info.comp_type != CT_FLOAT) {
|
||||
set_error("POSITION must be float vec3");
|
||||
return {};
|
||||
}
|
||||
if (!bin_data && pos_info.count > 0) {
|
||||
set_error("POSITION accessor requires BIN chunk, which is missing");
|
||||
return {};
|
||||
}
|
||||
|
||||
size_t nv = pos_info.count;
|
||||
std::vector<float> positions(nv * 3);
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float v[4]{};
|
||||
if (!read_float_vec(bin_view, CT_FLOAT, 3, pos_info.byte_offset,
|
||||
pos_info.byte_stride, i, v)) {
|
||||
set_error("out-of-bounds read in POSITION");
|
||||
return {};
|
||||
}
|
||||
positions[i*3+0] = v[0];
|
||||
positions[i*3+1] = v[1];
|
||||
positions[i*3+2] = v[2];
|
||||
}
|
||||
|
||||
// --- 6. Read NORMAL (optional) ---
|
||||
std::vector<float> normals;
|
||||
bool has_normals = false;
|
||||
if (attrs.contains("NORMAL")) {
|
||||
AccInfo nrm_info{};
|
||||
if (resolve_accessor(attrs["NORMAL"].get<int>(), nrm_info) &&
|
||||
nrm_info.ncomp == 3 && nrm_info.comp_type == CT_FLOAT &&
|
||||
nrm_info.count == nv) {
|
||||
normals.resize(nv * 3);
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float v[4]{};
|
||||
if (!read_float_vec(bin_view, CT_FLOAT, 3, nrm_info.byte_offset,
|
||||
nrm_info.byte_stride, i, v)) {
|
||||
set_error("out-of-bounds read in NORMAL");
|
||||
return {};
|
||||
}
|
||||
normals[i*3+0] = v[0];
|
||||
normals[i*3+1] = v[1];
|
||||
normals[i*3+2] = v[2];
|
||||
}
|
||||
has_normals = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 7. Read indices ---
|
||||
std::vector<uint32_t> indices;
|
||||
if (prim.contains("indices") && !prim["indices"].is_null()) {
|
||||
AccInfo idx_info{};
|
||||
int idx_acc = prim["indices"].get<int>();
|
||||
if (!resolve_accessor(idx_acc, idx_info)) {
|
||||
set_error("failed to resolve indices accessor");
|
||||
return {};
|
||||
}
|
||||
if (!bin_data && idx_info.count > 0) {
|
||||
set_error("indices accessor requires BIN chunk, which is missing");
|
||||
return {};
|
||||
}
|
||||
indices.resize(idx_info.count);
|
||||
for (size_t i = 0; i < idx_info.count; ++i) {
|
||||
if (!read_index(bin_view, idx_info.comp_type, idx_info.byte_offset, i, indices[i])) {
|
||||
set_error("out-of-bounds read in indices");
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No indices: interpret as sequential triangle list
|
||||
indices.resize(nv);
|
||||
for (size_t i = 0; i < nv; ++i) indices[i] = (uint32_t)i;
|
||||
}
|
||||
|
||||
// --- 8. Generate normals if missing (smooth, area-weighted) ---
|
||||
if (!has_normals) {
|
||||
normals.assign(nv * 3, 0.0f);
|
||||
size_t ntri = indices.size() / 3;
|
||||
for (size_t t = 0; t < ntri; ++t) {
|
||||
uint32_t i0 = indices[t*3+0];
|
||||
uint32_t i1 = indices[t*3+1];
|
||||
uint32_t i2 = indices[t*3+2];
|
||||
if (i0 >= nv || i1 >= nv || i2 >= nv) continue;
|
||||
|
||||
float e1[3] = {
|
||||
positions[i1*3+0] - positions[i0*3+0],
|
||||
positions[i1*3+1] - positions[i0*3+1],
|
||||
positions[i1*3+2] - positions[i0*3+2]
|
||||
};
|
||||
float e2[3] = {
|
||||
positions[i2*3+0] - positions[i0*3+0],
|
||||
positions[i2*3+1] - positions[i0*3+1],
|
||||
positions[i2*3+2] - positions[i0*3+2]
|
||||
};
|
||||
float face_n[3];
|
||||
cross3(e1, e2, face_n);
|
||||
// face_n magnitude = 2 * area → area weighting automatic
|
||||
for (uint32_t vi : {i0, i1, i2}) {
|
||||
normals[vi*3+0] += face_n[0];
|
||||
normals[vi*3+1] += face_n[1];
|
||||
normals[vi*3+2] += face_n[2];
|
||||
}
|
||||
}
|
||||
// Normalize per-vertex
|
||||
for (size_t i = 0; i < nv; ++i) {
|
||||
float* n = &normals[i*3];
|
||||
float l = len3(n);
|
||||
if (l > 1e-8f) { n[0]/=l; n[1]/=l; n[2]/=l; }
|
||||
else { n[0]=0; n[1]=1; n[2]=0; } // degenerate fallback
|
||||
}
|
||||
}
|
||||
|
||||
// --- 9. Apply node transform (first node referencing this mesh) ---
|
||||
bool applied_transform = false;
|
||||
if (j.contains("nodes") && !j["nodes"].empty()) {
|
||||
auto& nodes = j["nodes"];
|
||||
for (size_t ni = 0; ni < nodes.size() && !applied_transform; ++ni) {
|
||||
auto& node = nodes[ni];
|
||||
if (!node.contains("mesh") || node["mesh"].get<int>() != 0) continue;
|
||||
|
||||
float mat[16] = {
|
||||
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
|
||||
}; // identity column-major
|
||||
|
||||
if (node.contains("matrix") && node["matrix"].is_array() && node["matrix"].size() == 16) {
|
||||
for (int k = 0; k < 16; ++k)
|
||||
mat[k] = node["matrix"][k].get<float>();
|
||||
applied_transform = true;
|
||||
} else {
|
||||
float t[3] = {0,0,0}, q[4] = {0,0,0,1}, s[3] = {1,1,1};
|
||||
bool has_trs = false;
|
||||
if (node.contains("translation") && node["translation"].size() == 3) {
|
||||
for (int k = 0; k < 3; ++k) t[k] = node["translation"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (node.contains("rotation") && node["rotation"].size() == 4) {
|
||||
for (int k = 0; k < 4; ++k) q[k] = node["rotation"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (node.contains("scale") && node["scale"].size() == 3) {
|
||||
for (int k = 0; k < 3; ++k) s[k] = node["scale"][k].get<float>();
|
||||
has_trs = true;
|
||||
}
|
||||
if (has_trs) {
|
||||
trs_to_mat4(t, q, s, mat);
|
||||
applied_transform = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (applied_transform) {
|
||||
// Check if matrix is non-trivially identity
|
||||
const float id[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
|
||||
bool is_identity = true;
|
||||
for (int k = 0; k < 16; ++k)
|
||||
if (std::fabs(mat[k] - id[k]) > 1e-6f) { is_identity = false; break; }
|
||||
|
||||
if (!is_identity) {
|
||||
float nrm_mat[9];
|
||||
bool has_nrm_mat = compute_normal_matrix(mat, nrm_mat);
|
||||
|
||||
for (size_t vi = 0; vi < nv; ++vi) {
|
||||
float p[3] = { positions[vi*3+0], positions[vi*3+1], positions[vi*3+2] };
|
||||
float tp[3];
|
||||
mat4_mul_point(mat, p, tp);
|
||||
positions[vi*3+0] = tp[0];
|
||||
positions[vi*3+1] = tp[1];
|
||||
positions[vi*3+2] = tp[2];
|
||||
|
||||
if (has_nrm_mat) {
|
||||
float n[3] = { normals[vi*3+0], normals[vi*3+1], normals[vi*3+2] };
|
||||
float tn[3];
|
||||
nrm3x3_mul(nrm_mat, n, tn);
|
||||
float l = len3(tn);
|
||||
if (l > 1e-8f) { tn[0]/=l; tn[1]/=l; tn[2]/=l; }
|
||||
normals[vi*3+0] = tn[0];
|
||||
normals[vi*3+1] = tn[1];
|
||||
normals[vi*3+2] = tn[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Mesh m;
|
||||
m.positions = std::move(positions);
|
||||
m.normals = std::move(normals);
|
||||
m.indices = std::move(indices);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size) {
|
||||
return parse_glb(reinterpret_cast<const uint8_t*>(data), size);
|
||||
}
|
||||
|
||||
Mesh gltf_load_mesh_from_file(const char* path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) {
|
||||
std::snprintf(s_last_error, sizeof(s_last_error),
|
||||
"cannot open file: %s", path);
|
||||
return {};
|
||||
}
|
||||
auto file_size = f.tellg();
|
||||
if (file_size <= 0) { set_error("file is empty"); return {}; }
|
||||
f.seekg(0);
|
||||
std::vector<uint8_t> buf((size_t)file_size);
|
||||
if (!f.read(reinterpret_cast<char*>(buf.data()), file_size)) {
|
||||
set_error("file read failed");
|
||||
return {};
|
||||
}
|
||||
return parse_glb(buf.data(), buf.size());
|
||||
}
|
||||
|
||||
} // namespace fn::gfx
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include "gfx/mesh_obj_load.h" // fn::gfx::Mesh
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace fn::gfx {
|
||||
|
||||
// Carga el primer mesh (primera primitive del primer mesh) de un archivo GLB 2.0.
|
||||
//
|
||||
// Soporta:
|
||||
// - POSITION (vec3 float, obligatorio)
|
||||
// - NORMAL (vec3 float, opcional — si falta se generan normales smooth
|
||||
// area-weighted promediando las normales de cara de cada vertice)
|
||||
// - indices (ubyte/ushort/uint, escalares) — sin indices se interpreta como
|
||||
// lista de triangulos directa.
|
||||
//
|
||||
// Node transform: si el primer nodo que referencia el mesh tiene matrix o TRS,
|
||||
// se aplica a posiciones y normales (normales se transforman con la inversa transpuesta).
|
||||
//
|
||||
// Limitaciones (documentadas):
|
||||
// - Solo GLB (binario). .gltf+.bin separado y data-URIs base64 no soportados.
|
||||
// - Solo el primer mesh / primera primitive.
|
||||
// - Sin texturas ni materiales (mesh viewer usa color uniforme).
|
||||
// - Asume buffer 0 embebido en el chunk BIN.
|
||||
//
|
||||
// Retorna Mesh vacio (positions.empty()) si el parse falla.
|
||||
// El detalle del error esta disponible via gltf_load_last_error().
|
||||
Mesh gltf_load_mesh_from_file(const char* path);
|
||||
|
||||
// Variante pura (salvo el buffer): parsea GLB desde un bloque de memoria.
|
||||
// 'data' debe vivir al menos mientras dure la llamada.
|
||||
// Retorna Mesh vacio en fallo; gltf_load_last_error() da el detalle.
|
||||
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size);
|
||||
|
||||
// Descripcion del ultimo error de gltf_load_mesh_from_file /
|
||||
// gltf_load_mesh_from_memory. Valida hasta la siguiente llamada a cualquiera
|
||||
// de las dos funciones. Nunca retorna nullptr (puede ser "").
|
||||
const char* gltf_load_last_error();
|
||||
|
||||
} // namespace fn::gfx
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: gltf_load_mesh
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: gfx
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "Mesh gltf_load_mesh_from_file(const char* path); Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size); const char* gltf_load_last_error()"
|
||||
description: "Parser GLB 2.0 (glTF binario): carga el primer mesh/primitive a CPU como fn::gfx::Mesh. Soporta POSITION+NORMAL (vec3 float), indices ubyte/ushort/uint, node transform TRS/matrix. Genera normales smooth area-weighted si faltan. Sin dependencias externas — BIN chunk + nlohmann JSON vendored."
|
||||
tags: [mesh, gltf, glb, 3d, loader, geometry, gfx, mesh-3d]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [gfx/mesh_obj_load.h, nlohmann/json.hpp, fstream, cstring, cmath]
|
||||
tested: true
|
||||
tests:
|
||||
- "invalid magic -> empty Mesh + last_error set"
|
||||
- "too-small buffer -> empty Mesh + last_error set"
|
||||
- "triangle without NORMAL -> normals generated, correct count"
|
||||
- "quad (2 triangles) -> positions.size()==12, indices.size()==6"
|
||||
- "explicit normals -> passed through unchanged"
|
||||
- "nonexistent file -> empty Mesh + last_error set"
|
||||
test_file_path: "cpp/tests/test_gltf_load_mesh.cpp"
|
||||
file_path: "cpp/functions/gfx/gltf_load_mesh.cpp"
|
||||
framework: opengl
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta al archivo .glb. Solo GLB binario — .gltf+.bin separado y data-URI base64 no soportados."
|
||||
- name: data
|
||||
desc: "Puntero al buffer GLB en memoria. Debe vivir mientras dure la llamada."
|
||||
- name: size
|
||||
desc: "Longitud del buffer en bytes."
|
||||
output: "fn::gfx::Mesh con positions/normals (stride 3, mismo length) y indices uint32 (tri-list). Mesh vacio (positions.empty()==true) si parse falla. gltf_load_last_error() devuelve descripcion del error."
|
||||
notes: |
|
||||
Usa fn::gfx::Mesh de mesh_obj_load.h — mismo struct que consume mesh_gpu_upload().
|
||||
nlohmann vendored en cpp/vendor/nlohmann/json.hpp.
|
||||
El parser no aloca heap mas alla del Mesh de salida + JSON temporal.
|
||||
gltf_load_last_error() usa thread_local — seguro en multihilo siempre que
|
||||
cada hilo llame sus propias funciones.
|
||||
---
|
||||
|
||||
# gltf_load_mesh
|
||||
|
||||
Loader GLB 2.0 minimal para el registry. Parsea el contenedor GLB binario a mano
|
||||
(header 12 bytes + chunks JSON + BIN) usando nlohmann para el JSON. KISS: sin
|
||||
tinygltf ni dependencias extra.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
// Cargar .glb generado por TripoSR/trimesh y subir a GPU:
|
||||
#include "gfx/gltf_load_mesh.h"
|
||||
#include "gfx/mesh_gpu.h"
|
||||
|
||||
auto cpu = fn::gfx::gltf_load_mesh_from_file("model.glb");
|
||||
if (cpu.positions.empty()) {
|
||||
fprintf(stderr, "gltf load failed: %s\n", fn::gfx::gltf_load_last_error());
|
||||
return;
|
||||
}
|
||||
|
||||
// Subir a GPU (requiere contexto GL activo):
|
||||
auto gpu = fn::gfx::mesh_gpu_upload(cpu);
|
||||
if (!gpu.ok()) { /* fallo de upload GL */ return; }
|
||||
|
||||
glUseProgram(prog);
|
||||
glBindVertexArray(gpu.vao);
|
||||
glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0);
|
||||
glBindVertexArray(0);
|
||||
|
||||
fn::gfx::mesh_gpu_destroy(gpu);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Desde memoria (ej. respuesta HTTP o embedding):
|
||||
std::vector<unsigned char> glb_buf = download_glb(...);
|
||||
auto cpu = fn::gfx::gltf_load_mesh_from_memory(glb_buf.data(), glb_buf.size());
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando recibes un `.glb` (binario glTF 2.0) de un backend Python (TripoSR,
|
||||
trimesh, open3d) y necesitas renderizarlo en una app ImGui via `mesh_gpu_upload`.
|
||||
Tambien util para inspeccionar geometria en CPU sin subir a GPU.
|
||||
|
||||
## Limitaciones
|
||||
|
||||
- **Solo GLB binario**. `.gltf + .bin` separado: no soportado. Data URIs base64: no soportados.
|
||||
- **Primer mesh, primera primitive**. Archivos con multiples meshes o materiales: solo se carga el primero.
|
||||
- **Sin texturas ni materiales**. El Mesh solo contiene geometria (posicion + normal). El shader del viewer usa color uniforme.
|
||||
- **Buffer unico embebido** (chunk BIN). Referencias a buffers externos: no soportadas.
|
||||
- **Modo solo triangulos** (`"mode": 4`, default). Puntos, lineas, triangle-strip: no soportados.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `gltf_load_last_error()` es `thread_local`. Si usas multihilo, cada hilo tiene su propio error buffer — no compartas el puntero entre hilos.
|
||||
- El puntero que devuelve `gltf_load_last_error()` se sobreescribe en la siguiente llamada a `gltf_load_mesh_from_*`. Copia el string si lo necesitas despues.
|
||||
- Un `Mesh` retornado con `positions.empty() == true` es la senal de fallo — **no** lanzamos excepciones.
|
||||
- Para archivos grandes (>50 MB) la lectura es un `std::vector<uint8_t>` completo en memoria. Para streaming, usa `gltf_load_mesh_from_memory` con tu propio buffer.
|
||||
- El parser no valida que `indices` sean menores que `nv` en cada vertice — indices fuera de rango se saltan silenciosamente durante la generacion de normales pero pueden producir geometria incorrecta.
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)"
|
||||
description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats."
|
||||
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx]
|
||||
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx, mesh-3d]
|
||||
uses_functions: ["gl_loader_cpp_gfx", "mesh_obj_load_cpp_gfx"]
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -7,7 +7,7 @@ version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)"
|
||||
description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega."
|
||||
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d]
|
||||
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d, mesh-3d]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
|
||||
@@ -105,7 +105,7 @@ GLuint compile_program() {
|
||||
void ensure_init(Cache& c) {
|
||||
if (c.initialized) return;
|
||||
fn::gfx::gl_loader_init();
|
||||
fn::gfx::fb_init(c.fb);
|
||||
fn::gfx::fb_init_depth(c.fb);
|
||||
c.program = compile_program();
|
||||
if (c.program) {
|
||||
c.loc_view = glGetUniformLocation(c.program, "u_view");
|
||||
@@ -145,10 +145,9 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo);
|
||||
glViewport(0, 0, w, h);
|
||||
glClearColor(0.10f, 0.10f, 0.13f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
// No depth attachment in our FBO — fall back to back-to-front-ish via
|
||||
// GL_DEPTH_TEST off. For inspection meshes this is fine; documented.
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glEnable(GL_DEPTH_TEST);
|
||||
glDepthFunc(GL_LESS);
|
||||
|
||||
glUseProgram(c.program);
|
||||
auto m = fn::core::orbit_camera_matrices(*cfg.cam);
|
||||
@@ -183,7 +182,7 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
|
||||
// Restore GL state.
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
|
||||
glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]);
|
||||
if (prev_depth) glEnable(GL_DEPTH_TEST);
|
||||
if (prev_depth) glEnable(GL_DEPTH_TEST); else glDisable(GL_DEPTH_TEST);
|
||||
}
|
||||
|
||||
// Display.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user