Compare commits
34 Commits
auto/0129
...
8482b22390
| Author | SHA1 | Date | |
|---|---|---|---|
| 8482b22390 | |||
| 5b06fdba03 | |||
| c3f0017193 | |||
| d02792eebd | |||
| 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"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: install_chromium_proxy_extension
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: 1.0.0
|
||||
purity: impure
|
||||
signature: install_chromium_proxy_extension --ext-dir DIR [--name NAME] [--stable-dir DIR] [--uninstall]
|
||||
description: "Instala una extension desempaquetada de Chromium en todos los perfiles del usuario de forma persistente, escribiendo un fragmento en /etc/chromium.d/ que el wrapper de Chromium carga en cada arranque. Pensado para distribuir la extension de toggle de proxy de web_proxy sin Web Store, pero sirve para cualquier extension desempaquetada."
|
||||
tags: [web-proxy, chromium, extension, browser, proxy, install]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
params:
|
||||
- name: --ext-dir
|
||||
desc: "Directorio de la extension desempaquetada de origen (debe contener manifest.json). Obligatorio salvo en --uninstall."
|
||||
- name: --name
|
||||
desc: "Nombre del fragmento en /etc/chromium.d/ (default web_proxy_ext). Identifica esta instalacion para poder desinstalarla."
|
||||
- name: --stable-dir
|
||||
desc: "Ruta estable donde se copia la extension, independiente del repo (default ~/.web_proxy/extension). --load-extension apunta aqui."
|
||||
- name: --uninstall
|
||||
desc: "Elimina el fragmento de /etc/chromium.d/ y la copia estable. No requiere --ext-dir."
|
||||
output: "JSON en stdout: {installed|uninstalled, name, stable_dir, chromiumd, ext_id}. Requiere sudo para escribir en /etc/chromium.d/."
|
||||
file_path: bash/functions/browser/install_chromium_proxy_extension.sh
|
||||
---
|
||||
|
||||
# install_chromium_proxy_extension
|
||||
|
||||
Instala una extension desempaquetada de Chromium en **todos los perfiles** del
|
||||
usuario, de forma persistente, sin pasar por la Chrome Web Store.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Instalar la extension de toggle de proxy de web_proxy en todos los perfiles
|
||||
install_chromium_proxy_extension --ext-dir /home/enmanuel/fn_registry/apps/web_proxy/extension
|
||||
|
||||
# Desinstalarla
|
||||
install_chromium_proxy_extension --uninstall
|
||||
|
||||
# Otra extension, con nombre y ruta estable propios
|
||||
install_chromium_proxy_extension --ext-dir ~/mis-extensiones/foo --name foo_ext --stable-dir ~/.local/share/foo_ext
|
||||
```
|
||||
|
||||
Tras instalar, cierra y vuelve a abrir Chromium: la extension aparece en todos
|
||||
los perfiles, incluidos los que se creen despues.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas que una extension desempaquetada este presente en todos los
|
||||
perfiles de Chromium de una maquina (por ejemplo, un toggle de proxy de captura
|
||||
preconfigurado) y no quieres publicarla en la Web Store ni cargarla a mano en
|
||||
cada perfil. Es la pieza que hace que `web_proxy` quede "a un clic" en cualquier
|
||||
ventana de Chromium.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere sudo** para escribir en `/etc/chromium.d/`. Ten las credenciales
|
||||
cacheadas (`sudo -v`) antes de invocarla de forma no interactiva.
|
||||
- **Solo para el wrapper de Chromium de Debian/Ubuntu** (paquete `chromium`,
|
||||
no snap ni Google Chrome). El wrapper hace `source /etc/chromium.d/*` en cada
|
||||
arranque. Comprueba con `head -1 $(command -v chromium)` que es un script.
|
||||
- **`--enable-remote-extensions` es imprescindible** en estos builds: sin el,
|
||||
el wrapper anade `--disable-extensions-except` y `--disable-background-networking`,
|
||||
que deshabilitan toda extension que no venga por `--load-extension`. El
|
||||
fragmento generado lo incluye; por eso las demas extensiones del usuario
|
||||
siguen funcionando.
|
||||
- La extension se carga **desempaquetada** (`--load-extension`), no como `.crx`
|
||||
firmado. Chromium puede mostrar un aviso de "extensiones en modo desarrollador".
|
||||
El force-install via managed policy con `.crx` local + `update_url file://`
|
||||
no funciona con este wrapper (lo bloquea `--disable-extensions-except`).
|
||||
- El ID de la extension depende de `--stable-dir` (se deriva del path). Si
|
||||
cambias la ruta estable, el ID cambia.
|
||||
- No reinicia Chromium: los cambios aplican en el siguiente arranque del
|
||||
navegador.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.0.0 (2026-06-02) — version inicial. Instala/desinstala extension global via /etc/chromium.d con --enable-remote-extensions + --load-extension.
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_chromium_proxy_extension — instala una extension desempaquetada de
|
||||
# Chromium en TODOS los perfiles del usuario, de forma persistente, escribiendo
|
||||
# un fragmento en /etc/chromium.d/ que el wrapper de Chromium carga en cada
|
||||
# arranque.
|
||||
#
|
||||
# Por que /etc/chromium.d en vez de managed policy con .crx force_installed:
|
||||
# el wrapper de Chromium de Debian/Ubuntu (xtradeb y derivados), cuando NO se
|
||||
# pasa --enable-remote-extensions, anade --disable-extensions-except=<solo las
|
||||
# de --load-extension> y --disable-background-networking. Eso deshabilita
|
||||
# cualquier extension force_installed por policy y bloquea su update check. La
|
||||
# via fiable es habilitar --enable-remote-extensions y cargar la extension
|
||||
# desempaquetada con --load-extension, ambos inyectados de forma global desde
|
||||
# /etc/chromium.d/, que el wrapper hace `source` en cada lanzamiento.
|
||||
|
||||
install_chromium_proxy_extension() {
|
||||
local ext_dir=""
|
||||
local name="web_proxy_ext"
|
||||
local stable_dir="$HOME/.web_proxy/extension"
|
||||
local chromiumd="/etc/chromium.d"
|
||||
local uninstall="no"
|
||||
|
||||
# Permite sudo no interactivo via SUDO_ASKPASS (sudo -A) cuando se ejecuta
|
||||
# sin terminal (agentes, CI). Con terminal interactivo usa sudo normal.
|
||||
local SUDO="sudo"
|
||||
[[ -n "${SUDO_ASKPASS:-}" ]] && SUDO="sudo -A"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--ext-dir) ext_dir="$2"; shift 2 ;;
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--stable-dir) stable_dir="$2"; shift 2 ;;
|
||||
--uninstall) uninstall="yes"; shift ;;
|
||||
*) echo "ERROR: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$chromiumd" ]]; then
|
||||
echo "ERROR: $chromiumd no existe. Este Chromium no usa el wrapper con /etc/chromium.d." >&2
|
||||
echo " Comprueba 'head -1 \$(command -v chromium)'; si no es un wrapper shell, usa otra via." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Desinstalacion: quitar el fragmento global y la copia estable.
|
||||
if [[ "$uninstall" == "yes" ]]; then
|
||||
$SUDO rm -f "${chromiumd}/${name}" || {
|
||||
echo "ERROR: no se pudo eliminar ${chromiumd}/${name} (requiere sudo)." >&2
|
||||
return 1
|
||||
}
|
||||
rm -rf "$stable_dir"
|
||||
printf '{"uninstalled": true, "name": "%s"}\n' "$name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Instalacion: validar la extension de origen.
|
||||
if [[ -z "$ext_dir" || ! -f "${ext_dir}/manifest.json" ]]; then
|
||||
echo "ERROR: --ext-dir debe apuntar a un directorio con manifest.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Copiar la extension a una ubicacion estable, independiente del repo, para
|
||||
# que --load-extension no se rompa si el repo se mueve o se limpia.
|
||||
mkdir -p "$stable_dir" || {
|
||||
echo "ERROR: no se pudo crear $stable_dir." >&2
|
||||
return 1
|
||||
}
|
||||
# Vaciar destino y copiar el contenido del origen.
|
||||
rm -rf "${stable_dir:?}/"* 2>/dev/null
|
||||
cp -r "${ext_dir}/." "$stable_dir/" || {
|
||||
echo "ERROR: no se pudo copiar la extension a $stable_dir." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Escribir el fragmento que el wrapper carga en cada arranque. Se hace via
|
||||
# archivo temporal + sudo cp para no exponer el contenido por una tuberia.
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
printf 'export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --enable-remote-extensions --load-extension=%s"\n' "$stable_dir" > "$tmp"
|
||||
if ! $SUDO cp "$tmp" "${chromiumd}/${name}"; then
|
||||
rm -f "$tmp"
|
||||
echo "ERROR: no se pudo escribir ${chromiumd}/${name} (requiere sudo)." >&2
|
||||
return 1
|
||||
fi
|
||||
$SUDO chmod 0644 "${chromiumd}/${name}" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
|
||||
# ID de extension desempaquetada (deterministico: sha256 del path estable).
|
||||
local ext_id
|
||||
ext_id="$(python3 - "$stable_dir" <<'PY' 2>/dev/null
|
||||
import hashlib, sys
|
||||
h = hashlib.sha256(sys.argv[1].encode()).hexdigest()[:32]
|
||||
print(''.join(chr(ord('a') + int(c, 16)) for c in h))
|
||||
PY
|
||||
)"
|
||||
|
||||
printf '{"installed": true, "name": "%s", "stable_dir": "%s", "chromiumd": "%s/%s", "ext_id": "%s"}\n' \
|
||||
"$name" "$stable_dir" "$chromiumd" "$name" "$ext_id"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
install_chromium_proxy_extension "$@"
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: launch_chromium_proxy
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "launch_chromium_proxy [--proxy URL] [--profile DIR] [--url URL] [--ca-cert PATH] [--extra \"ARGS\"]"
|
||||
description: "Lanza Chromium (o Chrome) apuntando a un proxy HTTP/HTTPS local con un perfil completamente aislado del perfil real del usuario. Pensado para capturar trafico con un proxy de interceptacion (mitmproxy, Burp Suite) sin contaminar la sesion normal de navegacion. Emite un JSON con el PID del proceso lanzado."
|
||||
tags: [chromium, chrome, proxy, mitmproxy, burp, browser, web-proxy, intercept, tls]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--proxy URL"
|
||||
desc: "URL del proxy HTTP/HTTPS local, ej. http://127.0.0.1:8080. Se pasa a Chromium como --proxy-server=URL. Default: http://127.0.0.1:8080."
|
||||
- name: "--profile DIR"
|
||||
desc: "Directorio de perfil aislado para Chromium (--user-data-dir). Se crea automaticamente si no existe. Default: /tmp/chromium-proxy. Usar un path distinto por sesion si se quiere aislamiento total entre corridas."
|
||||
- name: "--url URL"
|
||||
desc: "URL inicial a abrir al arrancar el navegador. Opcional. Si se omite, Chromium abre su pagina de nueva pestana."
|
||||
- name: "--ca-cert PATH"
|
||||
desc: "Ruta a un CA cert PEM del proxy (ej. ~/.mitmproxy/mitmproxy-ca-cert.pem). Si se pasa, la funcion NO agrega --ignore-certificate-errors y asume que el usuario ya importo el CA en el perfil o en el sistema. Si se omite, se agrega --ignore-certificate-errors automaticamente para que el proxy MITM no rompa HTTPS (menos seguro)."
|
||||
- name: "--extra \"ARGS\""
|
||||
desc: "Flags extra que se pasan directamente a chromium, ej. --extra \"--disable-gpu --window-size=1280,800\". El valor completo debe ir entre comillas."
|
||||
output: "JSON en stdout: {\"pid\": <pid>, \"browser\": \"<binario>\", \"proxy\": \"<url>\", \"profile\": \"<dir>\", \"log\": \"<ruta_log>\"}. Mensajes de estado e informacion de CA en stderr. El navegador corre en background desacoplado de la sesion. Exit codes: 0=lanzado correctamente, 1=binario no encontrado o argumento invalido o error al crear el directorio de perfil."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/launch_chromium_proxy.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/browser/launch_chromium_proxy.sh
|
||||
|
||||
# Caso mas comun: interceptar trafico con mitmproxy corriendo en 8080
|
||||
# (sin CA instalado: --ignore-certificate-errors se aplica automaticamente)
|
||||
result=$(launch_chromium_proxy --proxy http://127.0.0.1:8080 --url https://httpbin.org/get)
|
||||
echo "$result"
|
||||
# {"pid":12345,"browser":"chromium","proxy":"http://127.0.0.1:8080","profile":"/tmp/chromium-proxy","log":"/tmp/chromium-proxy-12345.log"}
|
||||
|
||||
# Con CA cert instalado (mitmproxy): sin --ignore-certificate-errors
|
||||
launch_chromium_proxy \
|
||||
--proxy http://127.0.0.1:8080 \
|
||||
--ca-cert ~/.mitmproxy/mitmproxy-ca-cert.pem \
|
||||
--profile /tmp/mitm-session \
|
||||
--url https://api.ejemplo.com/v1/test
|
||||
|
||||
# Con Burp Suite en puerto 8081, perfil aislado y ventana de tamano fijo
|
||||
launch_chromium_proxy \
|
||||
--proxy http://127.0.0.1:8081 \
|
||||
--profile /tmp/burp-session \
|
||||
--extra "--window-size=1440,900" \
|
||||
--url https://app.objetivo.com
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesitas capturar y analizar trafico HTTPS de un navegador con mitmproxy, Burp Suite u otro proxy de interceptacion, sin tocar el perfil real del usuario ni sus cookies/credenciales guardadas. Ideal antes de hacer analisis de trafico de una app web o API, o al reproducir un flujo autenticado desde una sesion limpia.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Deteccion de binario en orden**: la funcion prueba `chromium`, `chromium-browser`, `google-chrome-stable`, `google-chrome`. En sistemas donde solo existe `google-chrome`, ese sera el binario usado. Si ninguno esta en el PATH, retorna exit 1. Instalar con `sudo apt install chromium` o `chromium-browser`.
|
||||
- **`--ignore-certificate-errors` sin `--ca-cert`**: este flag desactiva toda la validacion TLS del navegador. Es conveniente para empezar rapido, pero reduce la seguridad de la sesion. Para produccion o analisis de seguridad serio, instalar el CA del proxy en el sistema (`sudo cp mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy.crt && sudo update-ca-certificates`) o en el perfil de Chromium (chrome://settings/certificates), y pasar `--ca-cert` para que la funcion omita el flag inseguro.
|
||||
- **`--proxy-bypass-list="<-loopback>"`**: fuerza que el trafico loopback (127.0.0.1, localhost) TAMBIEN pase por el proxy. Sin esto, Chromium excluye loopback del proxy por defecto y no veras esas peticiones en mitmproxy. Si quieres el comportamiento estandar (excluir loopback), elimina este flag via `--extra`.
|
||||
- **Perfil persistente entre sesiones**: el perfil en `/tmp/chromium-proxy` (o el directorio que elijas) persiste entre ejecuciones. Si quieres una sesion 100% limpia cada vez, pasa `--profile /tmp/chromium-proxy-$$` (usa el PID del shell como sufijo) o borra el directorio antes de llamar a la funcion.
|
||||
- **`setsid` + `disown`**: el navegador se lanza desacoplado de la sesion del agente. Si la shell/sesion que llamo a la funcion termina, el proceso Chromium sigue vivo. Para matarlo, usar `kill <pid>` con el PID del JSON de salida.
|
||||
- **Log del navegador**: stdout y stderr de Chromium se redirigen a `/tmp/chromium-proxy-<pid>.log`. Si el navegador no arranca, revisar ese archivo para ver el error.
|
||||
- **Chrome STABLE 138+**: al igual que `chrome_load_extensions`, algunos flags de automatizacion estan bloqueados en Chrome STABLE. Para interceptacion de trafico `--proxy-server` y `--user-data-dir` siguen funcionando en todas las versiones. Esta funcion es compatible con Chrome/Chromium en cualquier canal.
|
||||
- **Multiples instancias**: si ya hay una instancia de Chromium corriendo con el mismo `--user-data-dir`, Chromium puede reusar esa instancia en lugar de abrir una nueva. Usar un directorio de perfil distinto por sesion concurrente.
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_chromium_proxy — Lanza Chromium apuntando a un proxy HTTP/HTTPS local con perfil aislado.
|
||||
|
||||
launch_chromium_proxy() {
|
||||
local proxy_url="http://127.0.0.1:8080"
|
||||
local profile_dir="/tmp/chromium-proxy"
|
||||
local start_url=""
|
||||
local ca_cert=""
|
||||
local extra_args=""
|
||||
local ext_dir=""
|
||||
|
||||
# Parsear argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--proxy)
|
||||
proxy_url="$2"; shift 2 ;;
|
||||
--profile)
|
||||
profile_dir="$2"; shift 2 ;;
|
||||
--url)
|
||||
start_url="$2"; shift 2 ;;
|
||||
--ca-cert)
|
||||
ca_cert="$2"; shift 2 ;;
|
||||
--ext)
|
||||
ext_dir="$2"; shift 2 ;;
|
||||
--extra)
|
||||
extra_args="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "ERROR: argumento desconocido: $1" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Detectar binario del navegador
|
||||
local browser_bin=""
|
||||
for candidate in chromium chromium-browser google-chrome-stable google-chrome; do
|
||||
if command -v "$candidate" &>/dev/null; then
|
||||
browser_bin="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$browser_bin" ]]; then
|
||||
echo "ERROR: no se encontro ningun binario Chromium/Chrome en el PATH." >&2
|
||||
echo " Probados: chromium, chromium-browser, google-chrome-stable, google-chrome." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Crear directorio de perfil si no existe
|
||||
if [[ ! -d "$profile_dir" ]]; then
|
||||
mkdir -p "$profile_dir" || {
|
||||
echo "ERROR: no se pudo crear el directorio de perfil: $profile_dir" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Construir argumentos del navegador
|
||||
local args=(
|
||||
"--user-data-dir=${profile_dir}"
|
||||
"--no-first-run"
|
||||
"--no-default-browser-check"
|
||||
)
|
||||
|
||||
# Proxy fijo opcional. Con "--proxy none" (o vacio) no se fija proxy en el
|
||||
# cmdline: util cuando una extension de proxy gestiona la conexion (toggle).
|
||||
if [[ -n "$proxy_url" && "$proxy_url" != "none" ]]; then
|
||||
args+=("--proxy-server=${proxy_url}" "--proxy-bypass-list=<-loopback>")
|
||||
fi
|
||||
|
||||
# Cargar una extension desempaquetada (--load-extension). Funciona en
|
||||
# Chromium (no en Chrome stable 138+). Para persistencia en todos los
|
||||
# perfiles se usa managed policy en su lugar.
|
||||
if [[ -n "$ext_dir" ]]; then
|
||||
args+=("--load-extension=${ext_dir}")
|
||||
fi
|
||||
|
||||
# Manejo de certificados TLS
|
||||
if [[ -n "$ca_cert" ]]; then
|
||||
# El usuario instalo el CA en el perfil; no ignorar errores de certificado.
|
||||
# (El CA se instala en el sistema o en el perfil antes de lanzar.)
|
||||
echo "INFO: CA cert declarado: $ca_cert" >&2
|
||||
echo "INFO: Asegurate de haber importado el CA en el perfil o en el sistema antes de navegar HTTPS." >&2
|
||||
else
|
||||
# Sin CA cert: ignorar errores de certificado para que mitmproxy/Burp funcionen sin configuracion extra.
|
||||
# ADVERTENCIA: esto desactiva la validacion TLS completa del navegador.
|
||||
args+=("--ignore-certificate-errors")
|
||||
echo "WARN: --ignore-certificate-errors activo. Usa --ca-cert si instalaste el CA del proxy." >&2
|
||||
fi
|
||||
|
||||
# URL inicial opcional
|
||||
if [[ -n "$start_url" ]]; then
|
||||
args+=("$start_url")
|
||||
fi
|
||||
|
||||
# Argumentos extra pasados por el usuario
|
||||
# shellcheck disable=SC2206
|
||||
local extra_arr=()
|
||||
if [[ -n "$extra_args" ]]; then
|
||||
read -r -a extra_arr <<< "$extra_args"
|
||||
args+=("${extra_arr[@]}")
|
||||
fi
|
||||
|
||||
# Log temporal para stderr/stdout del navegador
|
||||
local log_file="/tmp/chromium-proxy-$$.log"
|
||||
|
||||
# Lanzar en background desacoplado de la sesion del agente
|
||||
setsid "$browser_bin" "${args[@]}" </dev/null >"$log_file" 2>&1 &
|
||||
local browser_pid=$!
|
||||
disown "$browser_pid"
|
||||
|
||||
# Emitir JSON con informacion del proceso lanzado
|
||||
printf '{"pid":%d,"browser":"%s","proxy":"%s","profile":"%s","log":"%s"}\n' \
|
||||
"$browser_pid" "$browser_bin" "$proxy_url" "$profile_dir" "$log_file"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_chromium_proxy "$@"
|
||||
fi
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: query_mitm_flows
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "query_mitm_flows(file_or_glob: string, filter?: string, har?: string, mitmdump?: string) -> void"
|
||||
description: "Consulta capturas .mitm guardadas con mitmproxy: vuelca los flujos que coinciden con un filtro de mitmproxy a stdout, o exporta la captura a HAR. Acepta uno o varios archivos .mitm (incluyendo globs expandidos por el shell). Autodetecta mitmdump en PATH y $HOME/.local/bin."
|
||||
tags: [bash, cybersecurity, mitmproxy, web-proxy, query, har, capture, traffic, proxy, network]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_or_glob
|
||||
desc: "Ruta a un archivo .mitm o glob expandido por el shell (ej. ~/captures/traffic-*.mitm). Acepta múltiples archivos como argumentos posicionales."
|
||||
- name: filter
|
||||
desc: "Expresión de filtro mitmproxy (ej. '~m POST & ~u /api', '~c 500', '~d example.com'). Si se omite, vuelca todos los flujos."
|
||||
- name: har
|
||||
desc: "Ruta de salida para exportar en formato HAR (--set hardump=OUT). Si se omite, el volcado va a stdout."
|
||||
- name: mitmdump
|
||||
desc: "Ruta al binario mitmdump. Si se omite, autodetecta en PATH y luego en $HOME/.local/bin/mitmdump."
|
||||
output: "Vuelca los flujos capturados a stdout (modo default) o exporta a un archivo HAR (con --har); informa la ruta de exportación en stderr al terminar. Exit code de mitmdump propagado."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/query_mitm_flows.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/query_mitm_flows.sh
|
||||
|
||||
# Volcar todos los flujos de una captura
|
||||
query_mitm_flows ~/captures/traffic-20260602.mitm
|
||||
|
||||
# Filtrar solo peticiones POST a /api (glob expandido por el shell)
|
||||
query_mitm_flows ~/captures/traffic-20260602-*.mitm --filter "~m POST & ~u /api"
|
||||
|
||||
# Ver solo respuestas con código 500
|
||||
query_mitm_flows session.mitm --filter "~c 500"
|
||||
|
||||
# Exportar a HAR para abrir en el Network tab del browser DevTools
|
||||
query_mitm_flows ~/captures/traffic-20260602.mitm --har salida.har
|
||||
|
||||
# Exportar a HAR con filtro aplicado
|
||||
query_mitm_flows session.mitm --filter "~d example.com" --har example_flows.har
|
||||
|
||||
# Especificar mitmdump manualmente (ej. en un venv de Python)
|
||||
query_mitm_flows session.mitm --mitmdump ~/.venv/bin/mitmdump --filter "~m POST"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando hayas capturado tráfico HTTP/HTTPS con mitmproxy y necesites consultarlo después sin abrir una interfaz interactiva: filtrar flujos específicos por método, dominio, URL o código de respuesta, o exportar la captura a HAR para análisis posterior en herramientas externas (browser DevTools, Insomnia, Postman). Úsala desde scripts de análisis automatizados o pipelines de revisión de seguridad.
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Sintaxis de filtros mitmproxy** — los filtros se pasan como expresión entre comillas:
|
||||
|
||||
| Operador | Significado | Ejemplo |
|
||||
|---|---|---|
|
||||
| `~u <regex>` | URL (path + query) | `~u /api/login` |
|
||||
| `~d <regex>` | Dominio del host | `~d example.com` |
|
||||
| `~m <método>` | Método HTTP | `~m POST` |
|
||||
| `~c <código>` | Código de respuesta | `~c 500` |
|
||||
| `~bq <regex>` | Body del request | `~bq password` |
|
||||
| `~bs <regex>` | Body del response | `~bs token` |
|
||||
| `~t <regex>` | Content-Type | `~t application/json` |
|
||||
| `~s` | Solo respuestas | `~s` |
|
||||
| `~q` | Solo requests | `~q` |
|
||||
|
||||
Combinar con `&` (AND), `|` (OR), `!` (NOT). Ejemplos:
|
||||
- `"~m POST & ~u /api"` — POST a rutas /api
|
||||
- `"~c 500 | ~c 503"` — errores de servidor
|
||||
- `"~d example.com & !~u /static"` — todo de example.com excepto estáticos
|
||||
|
||||
**Globs:** el shell expande el glob *antes* de pasar los argumentos a la función. Pasar `~/captures/traffic-*.mitm` sin comillas para que el shell expanda; con comillas el glob llega literalmente y fallará si el archivo no existe.
|
||||
|
||||
**Inspección interactiva:** para navegar los flujos con UI, usar `mitmweb -r <file>` (web) o `mitmproxy -r <file>` (TUI), no esta función. Esta función es para consulta programática y scripting.
|
||||
|
||||
**Múltiples archivos con `-r`:** mitmdump acepta varios flags `-r` y concatena los flujos en orden. El filtro se aplica sobre el conjunto completo.
|
||||
|
||||
**HAR y filtros juntos:** al usar `--har` con `--filter`, solo los flujos que pasen el filtro se exportan al HAR.
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# query_mitm_flows — Consulta capturas .mitm con mitmdump: vuelca flujos a stdout
|
||||
# o exporta a HAR. Acepta uno o varios archivos (también globs expandidos por el shell).
|
||||
|
||||
query_mitm_flows() {
|
||||
local -a files=()
|
||||
local filter=""
|
||||
local har_out=""
|
||||
local mitmdump_bin=""
|
||||
|
||||
# ── Parseo de argumentos ────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--filter)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --filter requiere un valor" >&2; return 1; }
|
||||
filter="$2"; shift 2 ;;
|
||||
--har)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --har requiere una ruta de salida" >&2; return 1; }
|
||||
har_out="$2"; shift 2 ;;
|
||||
--mitmdump)
|
||||
[[ -z "${2:-}" ]] && { echo "ERROR: --mitmdump requiere la ruta al binario" >&2; return 1; }
|
||||
mitmdump_bin="$2"; shift 2 ;;
|
||||
--*)
|
||||
echo "ERROR: opcion desconocida: $1" >&2; return 1 ;;
|
||||
*)
|
||||
files+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validar que se paso al menos un archivo ─────────────────────────────────
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "ERROR: se requiere al menos un archivo .mitm como argumento" >&2
|
||||
echo "Uso: query_mitm_flows <file_or_glob> [--filter EXPR] [--har OUT] [--mitmdump BIN]" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Verificar que cada archivo existe ──────────────────────────────────────
|
||||
local -a valid_files=()
|
||||
for f in "${files[@]}"; do
|
||||
if [[ -f "$f" ]]; then
|
||||
valid_files+=("$f")
|
||||
else
|
||||
echo "ERROR: archivo no encontrado: $f" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#valid_files[@]} -eq 0 ]]; then
|
||||
echo "ERROR: ningun archivo valido encontrado (el patron no matcheo nada)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Autodetectar mitmdump ───────────────────────────────────────────────────
|
||||
if [[ -z "$mitmdump_bin" ]]; then
|
||||
if command -v mitmdump &>/dev/null; then
|
||||
mitmdump_bin="mitmdump"
|
||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
||||
else
|
||||
echo "ERROR: mitmdump no encontrado. Instala mitmproxy:" >&2
|
||||
echo " pip install mitmproxy o pip install --user mitmproxy" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -x "$(command -v "$mitmdump_bin" 2>/dev/null || echo "$mitmdump_bin")" ]]; then
|
||||
echo "ERROR: binario mitmdump no ejecutable: $mitmdump_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── Construir y ejecutar el comando ────────────────────────────────────────
|
||||
local -a cmd=("$mitmdump_bin" -n)
|
||||
|
||||
# Añadir todos los archivos de entrada con -r
|
||||
for f in "${valid_files[@]}"; do
|
||||
cmd+=(-r "$f")
|
||||
done
|
||||
|
||||
# Modo HAR
|
||||
if [[ -n "$har_out" ]]; then
|
||||
cmd+=(--set "hardump=${har_out}")
|
||||
fi
|
||||
|
||||
# Filtro de flujos (argumento posicional al final)
|
||||
if [[ -n "$filter" ]]; then
|
||||
cmd+=("$filter")
|
||||
fi
|
||||
|
||||
"${cmd[@]}"
|
||||
local exit_code=$?
|
||||
|
||||
if [[ -n "$har_out" && $exit_code -eq 0 ]]; then
|
||||
echo "HAR exportado a ${har_out}" >&2
|
||||
fi
|
||||
|
||||
return $exit_code
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
query_mitm_flows "$@"
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
name: start_mitm_capture
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: cybersecurity
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "start_mitm_capture([--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]) -> string"
|
||||
description: "Arranca mitmdump en modo headless en segundo plano como proxy de interceptación liviano, con rotación de capturas cada N minutos vía el addon rotate_capture_flows.py del registry. El proceso sobrevive al cierre de la shell (setsid). Emite JSON con PID, puerto, directorio de salida y ruta del log."
|
||||
tags: [bash, cybersecurity, mitmproxy, proxy, capture, web-proxy, network, interception, mitmdump]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--port N"
|
||||
desc: "Puerto TCP donde mitmdump escucha conexiones del cliente proxy. Default: 8080."
|
||||
- name: "--out DIR"
|
||||
desc: "Directorio donde se guardan los archivos .mitm rotados. Se crea automáticamente si no existe. Default: $HOME/captures."
|
||||
- name: "--rotate-min N"
|
||||
desc: "Minutos de duración de cada archivo de captura antes de rotar. Default: 20."
|
||||
- name: "--addon PATH"
|
||||
desc: "Ruta al addon Python de rotación (rotate_capture_flows.py). Default: derivado desde FN_REGISTRY_ROOT o desde la ubicación del propio script (3 niveles arriba)."
|
||||
- name: "--mitmdump BIN"
|
||||
desc: "Ruta al binario mitmdump. Default: autodetectado con command -v mitmdump, luego $HOME/.local/bin/mitmdump. Si no se encuentra, falla con instrucción de instalación."
|
||||
- name: "--log PATH"
|
||||
desc: "Archivo de log del proceso mitmdump. Default: <out>/mitmdump.log."
|
||||
output: "JSON en stdout: {\"pid\": <pid>, \"port\": <port>, \"out_dir\": \"<dir>\", \"rotate_min\": <n>, \"log\": \"<log>\"}. Exit 1 en error (binario no encontrado, addon ausente, proceso muerto al arrancar)."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/cybersecurity/start_mitm_capture.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/cybersecurity/start_mitm_capture.sh
|
||||
|
||||
# Arranque con defaults (puerto 8080, capturas en ~/captures, rotación cada 20 min)
|
||||
start_mitm_capture --port 8080 --out /home/enmanuel/captures --rotate-min 20
|
||||
|
||||
# Salida esperada:
|
||||
# {"pid": 123456, "port": 8080, "out_dir": "/home/enmanuel/captures", "rotate_min": 20, "log": "/home/enmanuel/captures/mitmdump.log"}
|
||||
|
||||
# Puerto alternativo y rotación más frecuente
|
||||
start_mitm_capture --port 9090 --out /tmp/mitm_session --rotate-min 5
|
||||
|
||||
# Pasando addon y binario explícitos
|
||||
start_mitm_capture \
|
||||
--port 8080 \
|
||||
--out /home/enmanuel/captures \
|
||||
--addon /home/enmanuel/fn_registry/python/functions/cybersecurity/rotate_capture_flows.py \
|
||||
--mitmdump /home/enmanuel/.local/bin/mitmdump
|
||||
|
||||
# Leer el PID para poder parar el proceso más adelante
|
||||
info=$(start_mitm_capture --port 8080 --out /home/enmanuel/captures)
|
||||
pid=$(echo "$info" | python3 -c "import sys,json; print(json.load(sys.stdin)['pid'])")
|
||||
echo "Proxy corriendo con PID $pid"
|
||||
# Para parar: kill "$pid"
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un proxy de interceptación pasivo siempre activo en segundo plano que capture y rote el tráfico HTTP/HTTPS de forma continua sin supervisión manual. Úsala antes de lanzar pruebas de integración, sesiones de auditoría web o scraping supervisado donde quieras replay o análisis posterior de las capturas `.mitm`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **HTTPS requiere instalar el CA de mitmproxy en el cliente.** El certificado raíz está en `~/.mitmproxy/mitmproxy-ca-cert.pem` tras el primer arranque. En navegadores: importar como CA de confianza. En curl/httpx: `--cacert ~/.mitmproxy/mitmproxy-ca-cert.pem`. En Chrome/Chromium headless: `--ignore-certificate-errors` (solo para pruebas).
|
||||
- **El proceso queda en background.** Para pararlo: `kill <pid>` (el PID se devuelve en el JSON) o `port_kill 8080` si tienes la función del registry disponible.
|
||||
- **El addon rotate_capture_flows.py debe existir.** La función lo busca en la raíz del registry derivada automáticamente; si el registry está en una ubicación no estándar, pasa `--addon` explícitamente o setea `FN_REGISTRY_ROOT`.
|
||||
- **setsid desacopla el proceso de la shell.** Si cierras la terminal, mitmdump sigue corriendo. Necesitas matar el PID manualmente o via systemd/supervisor si quieres ciclo de vida gestionado.
|
||||
- **El log crece sin límite.** El log de mitmdump no rota; solo rotan los archivos `.mitm` de capturas. Monitoriza el tamaño de `<out>/mitmdump.log` en sesiones largas.
|
||||
- **Solo funciona con mitmdump >= 9.x** (API de addons `--set` con parámetros por nombre). Versiones anteriores usan sintaxis distinta.
|
||||
|
||||
## Notas
|
||||
|
||||
La derivación de la raíz del registry cuando `FN_REGISTRY_ROOT` no está seteado asciende 3 niveles desde `bash/functions/cybersecurity/` hasta la raíz usando `dirname "${BASH_SOURCE[0]}"`. Esto funciona siempre que el script se sourcee desde su ubicación original en el registry.
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# start_mitm_capture — Arranca mitmdump en segundo plano como proxy de interceptación
|
||||
# con rotación de capturas cada N minutos vía addon Python del registry.
|
||||
|
||||
start_mitm_capture() {
|
||||
local port=8080
|
||||
local out_dir="$HOME/captures"
|
||||
local rotate_min=20
|
||||
local addon_path=""
|
||||
local mitmdump_bin=""
|
||||
local log_path=""
|
||||
|
||||
# Parseo de argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="$2"; shift 2 ;;
|
||||
--out) out_dir="$2"; shift 2 ;;
|
||||
--rotate-min) rotate_min="$2"; shift 2 ;;
|
||||
--addon) addon_path="$2"; shift 2 ;;
|
||||
--mitmdump) mitmdump_bin="$2"; shift 2 ;;
|
||||
--log) log_path="$2"; shift 2 ;;
|
||||
*)
|
||||
echo "ERROR: argumento desconocido: $1" >&2
|
||||
echo "Uso: start_mitm_capture [--port N] [--out DIR] [--rotate-min N] [--addon PATH] [--mitmdump BIN] [--log PATH]" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Derivar raíz del registry cuando FN_REGISTRY_ROOT no está seteado
|
||||
local registry_root
|
||||
if [[ -n "${FN_REGISTRY_ROOT:-}" ]]; then
|
||||
registry_root="$FN_REGISTRY_ROOT"
|
||||
else
|
||||
# bash/functions/cybersecurity/ -> 3 niveles arriba = raíz del registry
|
||||
registry_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
fi
|
||||
|
||||
# Default addon usando la raíz derivada
|
||||
if [[ -z "$addon_path" ]]; then
|
||||
addon_path="${registry_root}/python/functions/cybersecurity/rotate_capture_flows.py"
|
||||
fi
|
||||
|
||||
# Default log path (depende de out_dir, se resuelve después de crear el dir)
|
||||
# Se asigna más abajo si sigue vacío
|
||||
|
||||
# Crear directorio de capturas si no existe
|
||||
if [[ ! -d "$out_dir" ]]; then
|
||||
mkdir -p "$out_dir" || {
|
||||
echo "ERROR: no se pudo crear el directorio de capturas: $out_dir" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Asignar log por defecto ahora que out_dir existe
|
||||
if [[ -z "$log_path" ]]; then
|
||||
log_path="${out_dir}/mitmdump.log"
|
||||
fi
|
||||
|
||||
# Resolver binario mitmdump
|
||||
if [[ -z "$mitmdump_bin" ]]; then
|
||||
if command -v mitmdump &>/dev/null; then
|
||||
mitmdump_bin="$(command -v mitmdump)"
|
||||
elif [[ -x "$HOME/.local/bin/mitmdump" ]]; then
|
||||
mitmdump_bin="$HOME/.local/bin/mitmdump"
|
||||
else
|
||||
echo "ERROR: mitmdump no encontrado; instala con: uv tool install mitmproxy" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if [[ ! -x "$mitmdump_bin" ]]; then
|
||||
echo "ERROR: el binario indicado no existe o no es ejecutable: $mitmdump_bin" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verificar que el addon existe
|
||||
if [[ ! -f "$addon_path" ]]; then
|
||||
echo "ERROR: addon no encontrado: $addon_path" >&2
|
||||
echo " Asegúrate de que FN_REGISTRY_ROOT apunta a la raíz del registry" >&2
|
||||
echo " o pasa --addon con la ruta correcta a rotate_capture_flows.py" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Arrancar mitmdump en background con setsid (sobrevive al cierre de la shell).
|
||||
# El redirect </dev/null desacopla stdin: sin él, el proceso retiene el pipe
|
||||
# heredado y la shell sale con exit 144 (SIGTERM) al cerrarse. disown lo saca
|
||||
# de la tabla de jobs para que la shell no le envíe señales al terminar.
|
||||
setsid "$mitmdump_bin" \
|
||||
-s "$addon_path" \
|
||||
--set "rotate_min=${rotate_min}" \
|
||||
--set "capture_dir=${out_dir}" \
|
||||
--listen-port "$port" \
|
||||
</dev/null >> "$log_path" 2>&1 &
|
||||
local pid=$!
|
||||
disown "$pid" 2>/dev/null || true
|
||||
|
||||
# Esperar ~1s y verificar que el proceso sigue vivo
|
||||
sleep 1
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "ERROR: mitmdump murió al arrancar. Últimas líneas del log:" >&2
|
||||
tail -20 "$log_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Emitir JSON con información del proceso arrancado
|
||||
printf '{"pid": %d, "port": %d, "out_dir": "%s", "rotate_min": %d, "log": "%s"}\n' \
|
||||
"$pid" "$port" "$out_dir" "$rotate_min" "$log_path"
|
||||
}
|
||||
|
||||
# Ejecutar si se llama directamente (fn run / bash <file>)
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
start_mitm_capture "$@"
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,14 @@ git_push_if_ahead() {
|
||||
echo "[push] $repo_name ($branch, $ahead commits ahead)" >&2
|
||||
local push_out
|
||||
push_out=$(git -C "$abs_repo" push origin "$branch" 2>&1) || {
|
||||
echo "[error] $repo_name: $(echo "$push_out" | tail -1)"
|
||||
# Preservar las lineas con los keywords que el orquestador usa para
|
||||
# decidir el auto-recover (rejected / fast-forward / non-fast-forward).
|
||||
# Un `tail -1` plano se quedaba con la linea final de `hint:` y perdia
|
||||
# "[rejected]" + "Updates were rejected", impidiendo el recover.
|
||||
local reason
|
||||
reason=$(echo "$push_out" | grep -iE 'rejected|fast-forward|denied|permission|error:' | head -3 | tr '\n' ' ')
|
||||
[[ -z "$reason" ]] && reason=$(echo "$push_out" | tail -1)
|
||||
echo "[error] $repo_name: $reason"
|
||||
return 0
|
||||
}
|
||||
echo "$push_out" | tail -3 >&2
|
||||
|
||||
@@ -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
|
||||
@@ -164,21 +191,36 @@ full_git_push() {
|
||||
[[ -z "$repo" ]] && continue
|
||||
local repo_name
|
||||
repo_name="$(basename "$repo")"
|
||||
# Captura SOLO stdout como status_line (la decision de control); el
|
||||
# stderr (logs de git) va a la terminal. Mezclar ambos con 2>&1 metia
|
||||
# lineas "[push] ..." de stderr al principio del string y rompia el
|
||||
# glob `== "[error]"*` (anclado al inicio) del recover.
|
||||
local status_line
|
||||
status_line=$(git_push_if_ahead "$repo" 2>&1 || true)
|
||||
status_line=$(git_push_if_ahead "$repo" 2>/dev/null || true)
|
||||
|
||||
if [[ "$status_line" == *"non-fast-forward"* || "$status_line" == *"Updates were rejected"* || "$status_line" == "[error]"* ]]; then
|
||||
# Detectar push rechazado por remoto adelantado. Cubrimos las distintas
|
||||
# redacciones de git: "[rejected]", "non-fast-forward", "fetch first",
|
||||
# "Updates were rejected", "Note about fast-forwards", o cualquier
|
||||
# linea que git_push_if_ahead haya marcado como [error].
|
||||
if [[ "$status_line" == *"[error]"* || "$status_line" == *"rejected"* || "$status_line" == *"fast-forward"* || "$status_line" == *"fetch first"* ]]; then
|
||||
echo " [recover] $repo_name: push rechazado, intentando merge auto" >&2
|
||||
# Fetch para tener origin actualizado.
|
||||
git -C "$repo" fetch --quiet 2>/dev/null || true
|
||||
local upstream
|
||||
upstream=$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)
|
||||
if [[ -n "$upstream" ]]; then
|
||||
if git -C "$repo" merge --no-ff --no-edit "$upstream" 2>&1 | tail -3 >&2; then
|
||||
status_line=$(git_push_if_ahead "$repo" 2>&1 || true)
|
||||
# Evaluar el exit del MERGE, no el de tail. Antes
|
||||
# `git merge ... | tail -3` evaluaba el exit de tail (siempre 0),
|
||||
# asi un merge con conflictos se reportaba como exito.
|
||||
local merge_out merge_rc
|
||||
merge_out=$(git -C "$repo" merge --no-ff --no-edit "$upstream" 2>&1)
|
||||
merge_rc=$?
|
||||
echo "$merge_out" | tail -3 >&2
|
||||
if [[ "$merge_rc" -eq 0 ]]; then
|
||||
status_line=$(git_push_if_ahead "$repo" 2>/dev/null || true)
|
||||
else
|
||||
git -C "$repo" merge --abort 2>/dev/null || true
|
||||
status_line="[error] $repo_name: merge auto fallo, requiere intervencion manual"
|
||||
status_line="[error] $repo_name: merge auto fallo (conflictos), requiere intervencion manual"
|
||||
fi
|
||||
else
|
||||
status_line="[error] $repo_name: sin upstream, no se puede recuperar"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Submodule cpp/apps/chart_demo deleted from 026f514bb7
Submodule cpp/apps/shaders_lab deleted from dc9a970aff
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user