Compare commits
51 Commits
ba5d262c6c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 588d092858 | |||
| a90b7443e4 | |||
| e1e9bb7499 | |||
| 1430039688 | |||
| 935008ec3f | |||
| d89da1292d | |||
| 83f1d7c8d3 | |||
| 216cad4c12 | |||
| 167a7e5eb7 | |||
| b8ec97e477 | |||
| 40400c0b88 | |||
| 236a4740b0 | |||
| 1c4a4b9259 | |||
| 1c8a86594f | |||
| a76760edba | |||
| 4a0f0e9dc0 | |||
| 73f41a3474 | |||
| eb8dbf66a1 | |||
| 6bc97df5c0 | |||
| e769836b0d | |||
| 93756fbd0c | |||
| 0a6d1b8d17 | |||
| 82f1f1bd58 | |||
| 9a9b876400 | |||
| 5c253a26e2 | |||
| 10bfb846a8 | |||
| d996542f88 | |||
| 8742cb25be | |||
| 37aacfcfa9 | |||
| 029dbf57bd | |||
| 3f6b652f3f | |||
| 5b10b419a2 | |||
| e2c073b8b7 | |||
| 25054ff64e | |||
| 648ce63fc0 | |||
| 685224ccb2 | |||
| ae841ceedb | |||
| 736e019e19 | |||
| 1f93e9d502 | |||
| b75bd7e154 | |||
| e0fad0e82f | |||
| 830f2d34de | |||
| ccfa5bc78b | |||
| 729921e16e | |||
| efc9911925 | |||
| c65f1698ae | |||
| 516db8efc0 | |||
| fa09ff9866 | |||
| 6aec0413bb | |||
| ea6a3ec8a5 | |||
| 3c1061fbd8 |
+8
-3
@@ -21,9 +21,11 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
|
||||
|
||||
**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). Ver `.claude/rules/apps_subrepo.md`.
|
||||
**Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/<basename>` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.git/`. Cada `projects/<name>/` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps/<name>/` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`.
|
||||
|
||||
**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`.
|
||||
**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`.
|
||||
|
||||
**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/<p>/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006.
|
||||
|
||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||
|
||||
@@ -148,7 +150,7 @@ Cualquier `SELECT ... FROM functions/types/apps/proposals WHERE ...` plano se ha
|
||||
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
||||
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
||||
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
|
||||
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser, obsidian
|
||||
|
||||
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||
- Enums: `algebraic`(product|sum)
|
||||
@@ -193,6 +195,7 @@ Regla decisiva: antes de cada bloque de codigo, decide caso. Si dudas entre 2 y
|
||||
| `client._http.request(...)` directo cuando hay wrapper en el registry | Salta validacion del wrapper y telemetria | Usar wrapper; si la firma no cubre el caso, proponer extension via `fn proposal add` |
|
||||
| Scripts en `temp/` para composiciones que se repiten | Codigo se pierde y no se monitoriza | Pipeline en `python/functions/pipelines/` o pipeline Bash en `bash/functions/pipelines/` |
|
||||
| Imports `from <pkg> import *` en heredoc | Imposible saber que funcion del registry se uso | Imports explicitos `from <domain> import <name1>, <name2>` |
|
||||
| `claude -p` o `subprocess(["claude", "-p", ...])` para obtener una respuesta del modelo | Lento (cold start ~7-15s, carga MCP + CLAUDE.md), caro, sin control de tools | `ask_llm` (grupo `claude-direct`, API directa, arranque 0). Ver regla `llm_invocation.md` |
|
||||
|
||||
Excepciones autorizadas para `sqlite3` directo (no requieren MCP): `.schema`, `.tables`, `PRAGMA table_info`, `COUNT(*) GROUP BY`, JOINs custom entre tablas que el MCP no expone.
|
||||
|
||||
@@ -230,6 +233,8 @@ fn-registry/
|
||||
docs/ # Specs de diseño
|
||||
docs/templates/ # Plantillas de frontmatter
|
||||
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
||||
reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado)
|
||||
projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local)
|
||||
<artefacto>/playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-analizador
|
||||
description: "Agente analizador (Fase 4) del ciclo reactivo. Lee `e2e_checks` declarados en app.md, ejecuta la suite via `e2e_run_checks_go_infra`, evalua assertions activas, calcula drift de metricas vs historico, persiste resultado en `e2e_runs` de operations.db y devuelve veredicto caveman pass/fail. NO modifica codigo ni propone fixes — eso es trabajo de fn-mejorador (Fase 5)."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-constructor
|
||||
description: "Agente constructor (Fase 1) del ciclo reactivo. Construye funciones, tests y tipos en Go, Python, TypeScript y Bash para fn_registry."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-executor
|
||||
description: "Agente ejecutor (Fase 2) del ciclo reactivo. Prepara apps, ejecuta pipelines/funciones Go y Python, y registra ejecuciones en operations.db."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-mejorador
|
||||
description: "Agente mejorador (Fase 5) del ciclo reactivo. Lee resultados fallidos de fn-analizador desde `e2e_runs`/`assertion_results`, busca contexto en el registry, y crea proposals con evidencia trazable. NO modifica codigo: solo abre proposals para que un humano (o el bucle autonomo del issue 0069) decida."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-orquestador
|
||||
description: "Meta-orquestador (Fase 6) del ciclo reactivo. Toma un issue o task_spec y recorre CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR despachando a fn-constructor/executor/recopilador/analizador/mejorador hasta convergencia, estancamiento, timeout o tope de iteraciones. Trabaja SIEMPRE en rama sandbox `auto/<issue>`, NUNCA mergea a master, persiste progreso en `task_runs`. Issue 0069."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: fn-recopilador
|
||||
description: "Agente recopilador (Fase 3) del ciclo reactivo. Audita operations.db de apps, valida integridad de datos operativos (entities, relations, executions, assertions, logs), y verifica que la estructura del ejecutor esta correcta. Modo extra `design-e2e <app_id>`: propone bloque `e2e_checks` para que la fase 4 (fn-analizador) pueda validar la app sin iteracion humana."
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Bash, Glob, Grep, Edit
|
||||
---
|
||||
|
||||
|
||||
@@ -173,23 +173,39 @@ Si el build falla:
|
||||
- "undefined reference to render" → falta quitar `static` o falta el `#ifndef FN_TEST_BUILD` en main.cpp.
|
||||
- "multiple definition of main" → falta el `target_compile_definitions(... FN_TEST_BUILD)` en CMakeLists.
|
||||
|
||||
### 8. Ejecutar (headless en WSL)
|
||||
### 8. Ejecutar (headless preferente — sin parpadeo)
|
||||
|
||||
WSL no tiene GLX 4.3 nativo — los tests corren bajo `xvfb` con software renderer Mesa. Wrapper canonico:
|
||||
`fn::run_app_test` crea la ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`, ver `cpp/framework/app_base.cpp`). El contexto GL real se crea igual, así que el render que ejercita el Test Engine es fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo, no roba foco. Por eso los tests de frontend C++ corren headless por defecto, sin tocar el código de cada app.
|
||||
|
||||
Dos formas de lanzar, según el entorno:
|
||||
|
||||
```bash
|
||||
cd "$ROOT/cpp/build/linux_tests"
|
||||
TEST_BIN="$(find . -name "${APP_ARG}_tests" -type f -executable | head -1)"
|
||||
[ -z "$TEST_BIN" ] && { echo "no encuentro el binario de tests"; exit 1; }
|
||||
|
||||
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||
"$TEST_BIN" 2>&1
|
||||
if [ -n "$DISPLAY" ] && command -v glxinfo >/dev/null 2>&1 \
|
||||
&& glxinfo 2>/dev/null | grep -q "OpenGL core profile version"; then
|
||||
# Host con GL nativo (PC enmanuel, X11 + GPU): binario directo.
|
||||
# La ventana ya nace oculta -> sin parpadeo, y usa la GPU real (rapido).
|
||||
timeout 90 "$TEST_BIN" 2>&1
|
||||
else
|
||||
# CI / WSL sin GLX 4.3 nativo: display virtual en RAM + software Mesa.
|
||||
timeout 90 xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||
"$TEST_BIN" 2>&1
|
||||
fi
|
||||
EXIT=$?
|
||||
echo "EXIT: $EXIT"
|
||||
```
|
||||
|
||||
Si en el host el usuario tiene GL nativo y `DISPLAY` funciona, el wrapper xvfb-run sigue siendo seguro (ejecuta dentro de su propio display).
|
||||
Ambas vías son headless. `xvfb-run` sigue siendo seguro en host con display (corre en su propio display virtual), así que si el sniff de GL falla puedes usar siempre la rama xvfb.
|
||||
|
||||
**Para depurar un test a ojo** (ver la UI mientras el engine la maneja), desactiva el headless con `FN_HEADLESS=0`:
|
||||
|
||||
```bash
|
||||
FN_HEADLESS=0 timeout 90 "$TEST_BIN" 2>&1
|
||||
```
|
||||
|
||||
### 9. Reportar
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
description: "Modo launcher: das ordenes en lenguaje natural y Claude responde SOLO con la procedencia (registry/bash/heredoc) + el comando exacto, y lo ejecuta. Agiliza el lanzamiento de comandos y audita en vivo el Reg % (uso real de funciones del registry)."
|
||||
---
|
||||
|
||||
# /modo_launcher — lanzamiento rápido registry-first
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, el usuario da órdenes en lenguaje natural y Claude responde con el **mínimo absoluto**: la procedencia del comando + el comando exacto + por qué, y lo ejecuta. Sin prosa, sin explicaciones largas, sin preámbulos.
|
||||
|
||||
El objetivo es doble:
|
||||
|
||||
1. **Agilizar** el lanzamiento de comandos (cero verborrea entre orden y ejecución).
|
||||
2. **Auditar en vivo** que de verdad pasamos por funciones del registry antes que por bash inline — sube `Reg %` (objetivo 1 del Norte) y expone gaps reutilizables (objetivo 3).
|
||||
|
||||
## Activación
|
||||
|
||||
Al invocar `/modo_launcher` entras en **MODO LAUNCHER**. El modo permanece activo en todos los turnos siguientes hasta que el usuario escriba `salir` o `fin launcher`. No hay hook: el modo se sostiene por estas instrucciones mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el usuario puede re-invocar `/modo_launcher` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera:
|
||||
|
||||
```
|
||||
MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
|
||||
```
|
||||
|
||||
## Comportamiento por orden (regla dura)
|
||||
|
||||
Para CADA orden del usuario mientras el modo esté activo:
|
||||
|
||||
1. **Registry-first.** Mapea la orden a una capacidad y busca primero en el registry vía FTS (`mcp__registry__fn_search`) o reconoce un ID conocido. Las funciones del registry SIEMPRE tienen prioridad sobre bash inline.
|
||||
2. **Clasifica la procedencia** según la taxonomía de abajo.
|
||||
3. **Ejecuta directo.** Identificado el comando, ejecútalo sin pedir permiso — salvo que sea destructivo (ver guarda).
|
||||
4. **Responde en el formato fijo** (abajo), con la salida cruda del comando. Nada más.
|
||||
|
||||
## Formato de respuesta (OBLIGATORIO en cada orden)
|
||||
|
||||
```
|
||||
FUENTE: <etiqueta>
|
||||
CMD: <comando exacto>
|
||||
WHY: <razón: match FTS, ID conocido, o "sin función → bash">
|
||||
──────────
|
||||
<salida cruda del comando>
|
||||
```
|
||||
|
||||
- `FUENTE` es una de las etiquetas de la taxonomía.
|
||||
- `CMD` es el comando literal lanzado (forma `./fn run <id> [args]` para legibilidad aunque la ejecución real vaya por MCP).
|
||||
- `WHY` es una línea: qué match de búsqueda o qué ID justifica esa elección. Si fue un gap, dilo.
|
||||
- Tras la regla `──────────`, la salida cruda. Cero comentario después salvo que el usuario pregunte.
|
||||
|
||||
## Taxonomía de procedencia
|
||||
|
||||
| Etiqueta | Qué es | Cómo se ejecuta |
|
||||
|---|---|---|
|
||||
| `registry-run` | Ejecutar UNA función o pipeline del registry | `mcp__registry__fn_run <id> [args]` (preferido); fallback `./fn run <id> [args]` |
|
||||
| `registry-mcp` | Inspeccionar el registro (buscar, ver, código, deps, dominios) | `mcp__registry__fn_search` / `fn_show` / `fn_code` / `fn_uses` / `fn_list_domains` |
|
||||
| `heredoc` | Componer N funciones con lógica intermedia (loops, dispatch) | Heredoc `python/.venv/bin/python3 - <<'PY' ... PY` importando del registry |
|
||||
| `bash` | Comando shell puro: no existe función que lo cubra | Bash directo |
|
||||
| `gap` | No hay función Y el patrón parece reutilizable | Ejecuta el bash equivalente y marca el candidato (ver abajo) |
|
||||
|
||||
### Preferencia de ejecución para `registry-run`
|
||||
|
||||
- Usa `mcp__registry__fn_run` cuando esté disponible (queda registrado en `call_monitor`, alimenta el bucle reactivo).
|
||||
- Si el MCP `fn_run` no está habilitado (requiere `--enable-run`), cae a `./fn run <id>` por terminal. La línea `CMD` muestra siempre la forma `./fn run <id>` por legibilidad.
|
||||
|
||||
## Gaps: orden sin función en el registry
|
||||
|
||||
Cuando una orden no tenga función que la cubra:
|
||||
|
||||
1. Ejecuta el bash equivalente (`FUENTE: bash`).
|
||||
2. Si el patrón parece **reutilizable** (firma genérica, se repetiría en otras tareas, ≥5 líneas de lógica), añade tras la salida UNA línea:
|
||||
|
||||
```
|
||||
CANDIDATO → <nombre_propuesto>_<lang>_<domain>: <1 frase de qué haría>
|
||||
```
|
||||
|
||||
No lances `fn-constructor` automáticamente dentro del modo (rompería el ritmo de lanzamiento). Solo marca. El usuario decide al salir si promueve los candidatos.
|
||||
|
||||
## Guarda de comandos destructivos
|
||||
|
||||
Ejecuta directo SALVO que el comando sea irreversible o de alto impacto. En esos casos, NO ejecutes: muestra el bloque con `FUENTE`/`CMD`/`WHY` y añade `⚠ DESTRUCTIVO — confirma con 'ok'` en vez de la salida. Espera el `ok` explícito del usuario antes de lanzar.
|
||||
|
||||
Patrones que exigen confirmación:
|
||||
|
||||
- `rm -rf`, borrado de archivos versionados, `> archivo` sobre archivos trackeados.
|
||||
- `git push --force`, `git reset --hard`, `git clean`, borrado de ramas.
|
||||
- SQL `DROP`, `DELETE` sin `WHERE`, `TRUNCATE`, borrar cualquier `.db`.
|
||||
- `deploy`, `systemctl stop/restart/disable` de services, `fn sync` (escribe en el servidor).
|
||||
- `kill -9` masivo, `format`, `mkfs`, `dd`, cambios en `fstab`.
|
||||
|
||||
Para todo lo demás (lecturas, búsquedas, `fn run` de funciones puras o idempotentes, `git status/add/commit`, listados), ejecuta directo.
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el usuario escriba `salir` o `fin launcher`, cierra el modo con un resumen caveman de una tabla:
|
||||
|
||||
```
|
||||
=== fin MODO LAUNCHER ===
|
||||
ordenes: N
|
||||
registry: X (run A / mcp B)
|
||||
bash: Y
|
||||
gaps: Z → [lista de candidatos marcados]
|
||||
Reg %: X/(X+Y) de las ordenes ejecutables golpearon el registry
|
||||
```
|
||||
|
||||
Si hubo candidatos a función (`gap`), recuérdalos y pregunta si promover alguno vía `fn-constructor`.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **Registry-first siempre.** Nunca escribas bash inline para lógica que ya es función del registry. Busca antes de teclear.
|
||||
- **Cero prosa fuera del bloque.** Nada de "Claro, voy a…". Solo el bloque `FUENTE/CMD/WHY/salida`.
|
||||
- **Una orden = un bloque.** Si la orden necesita varios pasos, encadénalos en un solo `CMD` o numera los bloques, pero mantén el formato.
|
||||
- **No inventes IDs.** Si no encuentras función por búsqueda, es `gap`, no un `registry-run` falso.
|
||||
- **El modo no exime de las reglas del repo** (`registry_calls.md`, `purity.md`, etc.). Solo cambia el estilo de respuesta.
|
||||
|
||||
## Ejemplo de sesión
|
||||
|
||||
```
|
||||
tú: /modo_launcher
|
||||
yo: MODO LAUNCHER activo. Da ordenes. 'salir' para terminar.
|
||||
|
||||
tú: busca funciones de slice
|
||||
yo: FUENTE: registry-mcp
|
||||
CMD: mcp__registry__fn_search query="slice"
|
||||
WHY: inspección directa del registro
|
||||
──────────
|
||||
filter_slice_go_core — Filtra slice por predicado
|
||||
chunk_slice_go_core — Parte slice en lotes de N
|
||||
...
|
||||
|
||||
tú: mata lo que escuche en el puerto 8484
|
||||
yo: FUENTE: registry-run
|
||||
CMD: ./fn run port_kill_bash_infra 8484
|
||||
WHY: match FTS 'port kill' → port_kill_bash_infra
|
||||
──────────
|
||||
killed pid 33120 on :8484
|
||||
|
||||
tú: enséñame el uso de disco de home
|
||||
yo: FUENTE: bash
|
||||
CMD: du -sh /home/enmanuel/* | sort -rh | head
|
||||
WHY: sin función → utilidad de sistema puntual
|
||||
──────────
|
||||
12G /home/enmanuel/fn_registry
|
||||
...
|
||||
CANDIDATO → disk_usage_top_bash_shell: top-N directorios por tamaño en una ruta
|
||||
|
||||
tú: salir
|
||||
yo: === fin MODO LAUNCHER ===
|
||||
ordenes: 3
|
||||
registry: 2 (run 1 / mcp 1)
|
||||
bash: 1
|
||||
gaps: 1 → disk_usage_top_bash_shell
|
||||
Reg %: 2/3 (67%)
|
||||
1 candidato marcado. ¿Promuevo disk_usage_top_bash_shell vía fn-constructor?
|
||||
```
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `registry_calls.md` — el modo es una capa de estilo sobre los tres patrones canónicos (inspect / run / compose).
|
||||
- `registry_first.md` — el modo materializa "buscar antes de escribir" en cada orden.
|
||||
- `function_growth_and_self_docs.md` — los candidatos marcados alimentan la promoción de patrones inline a funciones.
|
||||
- `kiss.md` — sin hook, sin estado en disco: el modo vive solo en estas instrucciones.
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: orquestador
|
||||
description: "Modo orquestador: el Claude principal NO hace el trabajo pesado — descompone la tarea y lanza Claudes SECUNDARIOS interactivos, cada uno en su propia terminal kitty con un prompt autonomo y aislamiento git impuesto. El humano habla solo con el orquestador, ve a los secundarios en sus kitties y puede saltar a cualquiera. El orquestador sigue la flota, lee sus reports e integra. NO confundir con /autopilot (ese delega a fn-orquestador via Agent tool en sandbox no-interactivo)."
|
||||
---
|
||||
|
||||
# /orquestador — coordinar Claudes secundarios interactivos en kitty
|
||||
|
||||
Activa un **modo de comportamiento** persistente. Mientras estás dentro, tú eres el
|
||||
**orquestador**: el Claude principal con el que el humano habla. Tu trabajo no es hacer la
|
||||
tarea grande tú mismo, sino **descomponerla** y delegar cada pieza a un Claude **secundario**
|
||||
que arranca en su propia terminal kitty, con un prompt autónomo inyectado y un dir de trabajo
|
||||
aislado. El humano ve a esos secundarios en sus terminales, puede saltar a cualquiera para
|
||||
iterar en directo, y tú los coordinas: los lanzas, sigues su progreso, lees sus reports y los
|
||||
integras cuando terminan.
|
||||
|
||||
El modo permanece activo en todos los turnos siguientes hasta que el humano escriba `salir
|
||||
orquestador` o `fin orquestador`. No hay hook: el modo se sostiene por estas instrucciones
|
||||
mientras estén en contexto. Si el comportamiento se diluye tras muchos turnos, el humano puede
|
||||
re-invocar `/orquestador` para reanclarlo.
|
||||
|
||||
Al entrar, responde con una sola línea de confirmación y queda a la espera de la tarea grande:
|
||||
|
||||
```
|
||||
MODO ORQUESTADOR activo. Dame la tarea grande; la descompongo y lanzo secundarios. 'fin orquestador' para terminar.
|
||||
```
|
||||
|
||||
## Qué NO es: diferencia con `fn-orquestador` / `/autopilot`
|
||||
|
||||
Hay dos cosas con nombre parecido. No las confundas:
|
||||
|
||||
| | **Modo orquestador** (este comando) | **`fn-orquestador`** (subagent / `/autopilot`) |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claudes **interactivos** en terminales **kitty** | Lanza un sub-agente via el **Agent tool** (no interactivo) |
|
||||
| Visibilidad | El humano **ve y habla** con cada secundario en su kitty | El sub-agente corre headless; el humano no lo ve |
|
||||
| Persistencia | El secundario **vive en su terminal**, se puede retomar (`claude --resume`) | El sub-agente termina y devuelve su texto final |
|
||||
| Aislamiento | worktree / sub-repo / scope de archivos, impuesto en el prompt | worktree `auto/<issue>` gestionado por el propio `fn-orquestador` |
|
||||
| Gobierno | El humano coordina via el orquestador; iteración en vivo | Bucle autónomo CONSTRUIR→EJECUTAR→...→MEJORAR hasta converger, PR draft |
|
||||
| Regla de referencia | esta página | `.claude/rules/autonomous_loop.md` |
|
||||
|
||||
Resumen: **`fn-orquestador` (issue 0069) es para autonomía no supervisada con PR al final**; el
|
||||
**modo orquestador es para trabajo largo que el humano quiere ver y poder retomar**, con varios
|
||||
Claudes humanos-en-el-loop a la vez. Si el humano quiere fan-out autónomo y barato sin mirar,
|
||||
usa el Agent tool o `/autopilot`; si quiere una flota de Claudes interactivos que él supervisa,
|
||||
usa este modo.
|
||||
|
||||
## El ciclo del orquestador (8 pasos)
|
||||
|
||||
### 1. Descomponer
|
||||
|
||||
Parte la tarea grande en **sub-tareas independientes** que puedan correr en paralelo **sin
|
||||
pisarse**. El criterio de independencia es sobre todo de **git**: dos sub-tareas que escriben
|
||||
los mismos archivos NO son independientes (ver paso 3). Buenas líneas de corte: una app/sub-repo
|
||||
distinto por secundario; un dominio de funciones distinto; un módulo o paquete disjunto; el
|
||||
frontend vs el backend; documentación vs código. Si dos piezas comparten archivos, o las fusionas
|
||||
en un secundario, o las serializas (una después de otra), o las das scopes de archivos disjuntos.
|
||||
|
||||
### 2. Lanzar cada secundario
|
||||
|
||||
Comando canónico de lanzamiento (memoria `lanzar-agentes-skip-permissions`), **siempre** con
|
||||
`--dangerously-skip-permissions` porque los secundarios trabajan autónomos y desatendidos y los
|
||||
prompts de permiso en cada Bash los atascarían:
|
||||
|
||||
```bash
|
||||
setsid nohup kitty --title "<PROYECTO> · <subtarea>" --directory <dir-aislado> \
|
||||
zsh -ic 'claude --dangerously-skip-permissions "$(cat /tmp/orq_<slug>.md)"; exec zsh' \
|
||||
>/tmp/orq_<slug>_kitty.log 2>&1 & disown
|
||||
```
|
||||
|
||||
`setsid nohup ... & disown` hace que la kitty sobreviva al cierre de la terminal padre. El
|
||||
`zsh -ic '...; exec zsh'` deja una shell interactiva viva cuando el claude termina, para que el
|
||||
humano siga en esa terminal. El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque.
|
||||
|
||||
**Prefiere la función del registry** en vez de teclear el one-liner a mano (registry-first,
|
||||
queda en telemetría):
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "<PROYECTO> · <subtarea>" <dir-aislado> /tmp/orq_<slug>.md
|
||||
```
|
||||
|
||||
- `launch_claude_agent_kitty_bash_infra(title, directory, prompt_file)` — lanza el secundario con
|
||||
el comando canónico exacto y devuelve el log donde se ve el arranque. Valida que el dir y el
|
||||
prompt_file existan y que kitty esté instalado.
|
||||
|
||||
### 3. Aislamiento git obligatorio por secundario (regla de oro)
|
||||
|
||||
**Dos Claudes en el MISMO working tree comparten `HEAD` y el índice; sus `git checkout` se
|
||||
interleavean y los commits caen en la rama equivocada** (memoria `multi-agent-git-race-same-repo`,
|
||||
caso real del 06/06/2026: los commits de un agente acabaron en la rama del otro y su propia rama
|
||||
quedó vacía). Por eso **cada secundario trabaja en un espacio aislado**, y el orquestador elige
|
||||
cuál y se lo **impone** en el prompt del secundario:
|
||||
|
||||
| Opción | Cómo | Cuándo |
|
||||
|---|---|---|
|
||||
| **(a) Sub-repo Gitea propio** | El secundario trabaja dentro de `apps/<x>/`, `analysis/<x>/`, `projects/<p>/...` — cada uno tiene su `.git` independiente (regla `apps_subrepo.md`) | Cuando las sub-tareas caen en apps/analyses/projects distintos. Es el aislamiento natural del monorepo. |
|
||||
| **(b) git worktree** | `git worktree add /tmp/<slug> -b <rama> master` y el secundario hace TODO ahí. Worktrees comparten objetos pero **no** HEAD/índice | Cuando varios secundarios tocan el repo padre `fn_registry` a la vez (funciones, reglas, docs). |
|
||||
| **(c) Scope de archivos disjunto** | Mismo working tree pero cada secundario commitea **solo sus paths**: `git add <paths-específicos>`, **nunca** `git add -A` | Último recurso, solo si los scopes están garantizados disjuntos y no hay `git checkout` de rama de por medio. Frágil; prefiere (a) o (b). |
|
||||
|
||||
Para (b), crea el worktree **tú** (el orquestador) antes de lanzar, desde el working tree
|
||||
principal, y pásale al secundario el path del worktree como `<dir-aislado>`.
|
||||
|
||||
### 4. El prompt de cada secundario
|
||||
|
||||
Lo escribes tú en `/tmp/orq_<slug>.md` antes de lanzar. El secundario **no ve este historial**;
|
||||
el prompt debe ser **autocontenido**. Incluye SIEMPRE:
|
||||
|
||||
1. **Objetivo claro** — qué construir/arreglar, acotado y verificable.
|
||||
2. **Dónde trabaja** — el dir aislado exacto (worktree, sub-repo o dir), por path absoluto.
|
||||
3. **Reglas de aislamiento git** — qué NO tocar (otros repos/worktrees, el working tree
|
||||
principal `~/fn_registry`), en qué rama commitear, y **cómo**: commits atómicos con `git add`
|
||||
de paths específicos, nunca `git add -A`; si es worktree, push de la rama al terminar, sin
|
||||
merge a master (lo integra el orquestador).
|
||||
4. **Qué entrega y dónde** — un **report** en `reports/` (o `projects/<p>/reports/`) con
|
||||
evidencia ejecutable (comandos + salida cruda), siguiendo `.claude/rules/reports.md` y
|
||||
`.claude/rules/dod_quality.md`. Reports son artefacto local gitignored: se escriben, no se
|
||||
commitean.
|
||||
5. **Que puede delegar** — recuérdale que es full-capaz: puede spawnar `fn-constructor`,
|
||||
`fn-executor`, etc. via el Agent tool, y debe seguir registry-first (`registry_calls.md`,
|
||||
`delegation.md`).
|
||||
6. **La coletilla**: *"reporta tu progreso en esta terminal"* — para que el humano que mire la
|
||||
kitty vea el estado sin abrir el report.
|
||||
|
||||
Mira `/tmp/unibus_agent_*.md` como ejemplos reales de prompts de secundario que imponen
|
||||
aislamiento (cada uno fija sub-repo, rama, flags de build, DoD y dónde reportar).
|
||||
|
||||
### 5. Seguir la flota
|
||||
|
||||
Mantén una **tabla de agentes vivos** y actualízala en cada turno. La fuente de verdad del
|
||||
mapeo PID→sessionId→cwd son los archivos `~/.claude/sessions/<PID>.json` (memoria
|
||||
`claude-session-pid-mapping`). Usa la función del registry para listarla:
|
||||
|
||||
```bash
|
||||
./fn run list_claude_agents # tabla: PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD
|
||||
./fn run list_claude_agents --json # para parsear y decidir
|
||||
```
|
||||
|
||||
- `list_claude_agents_bash_infra([--json] [--exclude-current])` — cruza `pgrep -x claude` con los
|
||||
`sessions/<PID>.json` (con validación anti-PID-reciclado), marca tu propia sesión como `SELF`,
|
||||
y reporta cwd + sessionId de cada secundario (para retomar con `claude --resume <sessionId>`).
|
||||
|
||||
Tu tabla de seguimiento, una fila por secundario:
|
||||
|
||||
| slug | título kitty | PID | cwd / dir aislado | rama | log | report | estado |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| docs | fn_registry · docs | 3637133 | /tmp/orq_docs_wt | orq/docs | /tmp/orq_docs_kitty.log | reports/00NN-…-docs.md | en curso |
|
||||
|
||||
Cuando un secundario parezca terminado, confirma: ¿pusheó la rama? ¿escribió el report? Lee el
|
||||
report (`reports/`), revisa los commits de su rama (`git -C <dir> log --oneline`).
|
||||
|
||||
### 6. NUNCA `pkill`/`killall` sobre claude
|
||||
|
||||
Un `pkill claude` o `killall claude` **te mata a ti mismo** (el orquestador) junto con la flota.
|
||||
Para parar un secundario:
|
||||
|
||||
- **Kill por PID exacto** del secundario (lo tienes en la tabla / `list_claude_agents`):
|
||||
`kill <PID>` (o `kill <KITTY_PID>` para cerrar su ventana). Verifica que NO es tu `SELF`.
|
||||
- **`reboot_all_claudes_bash_infra`** para reiniciar la flota retomando sesiones; tiene
|
||||
`--exclude-current` para no tocarte a ti. Es dry-run por defecto; `--go` para ejecutar.
|
||||
|
||||
### 7. Integrar
|
||||
|
||||
Cuando un secundario termina (rama pusheada + report verde):
|
||||
|
||||
1. **Revisa** su diff y su report. Si el report no trae evidencia ejecutable o falla la DoD,
|
||||
devuélvele trabajo (el humano puede saltar a su kitty, o tú le mandas otro prompt).
|
||||
2. **Mergea si procede** desde el **working tree principal** (ahí suele estar `master`
|
||||
checked-out): `git -C ~/fn_registry merge --no-ff <rama>` para apps con TBD, o el flujo que
|
||||
corresponda al sub-repo. Para funciones nuevas del registry padre, sus archivos viajan en la
|
||||
rama y el merge los lleva a master.
|
||||
3. **Informa al humano** y **resume el estado de la flota** en cada turno: quién terminó, quién
|
||||
sigue, qué se integró, qué falta.
|
||||
|
||||
### 8. kitty vs Agent tool — cuándo cada uno
|
||||
|
||||
- **kitty (este modo)**: trabajo **largo e interactivo** que el humano quiere **ver** y poder
|
||||
**retomar** — implementar una feature de horas, depurar en vivo, una sesión que evoluciona.
|
||||
- **Agent tool directo**: fan-out **acotado y no interactivo** — buscar en el codebase, crear
|
||||
una función con `fn-constructor`, auditar N apps con `fn-recopilador`. Más barato, sin
|
||||
terminal, sin supervisión humana. Para esto NO lances kitty: usa `Agent(...)` y ya.
|
||||
|
||||
Regla práctica: si el humano va a querer hablar con ello o mirarlo trabajar → kitty. Si es una
|
||||
sub-tarea que devuelve un resultado y se acabó → Agent tool.
|
||||
|
||||
## Reglas duras del modo
|
||||
|
||||
- **El orquestador no hace el trabajo pesado.** Descompone, lanza, sigue, integra. Si te
|
||||
encuentras escribiendo tú la feature, párate: ¿no debería ser un secundario?
|
||||
- **Cada secundario, su aislamiento.** Nunca lances dos secundarios sobre el mismo working tree
|
||||
sin worktrees/sub-repos/scopes disjuntos. Es la causa nº1 de commits perdidos.
|
||||
- **El prompt del secundario lleva SIEMPRE las reglas de aislamiento.** Un prompt sin "trabaja
|
||||
aquí, no toques aquello, commitea así" es un secundario que contaminará otro repo.
|
||||
- **Nunca `git add -A` en un secundario** salvo que su dir aislado sea exclusivamente suyo
|
||||
(worktree/sub-repo). En scope compartido, paths específicos.
|
||||
- **Nunca `pkill`/`killall claude`.** Kill por PID exacto o `reboot_all_claudes --exclude-current`.
|
||||
- **El humano habla contigo.** Tú resumes la flota; no le hagas perseguir 5 terminales.
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | En su lugar |
|
||||
|---|---|---|
|
||||
| `pkill claude` para parar la flota | Te mata a ti (el orquestador) también | Kill por PID exacto / `reboot_all_claudes --exclude-current` |
|
||||
| Dos secundarios en el mismo working tree | Comparten HEAD/índice → commits dispersos, ramas vacías | worktree / sub-repo / scope disjunto por secundario |
|
||||
| Prompt de secundario sin reglas de aislamiento | El secundario contamina el repo padre u otro worktree | El prompt fija dir, qué NO tocar, rama y cómo commitear |
|
||||
| `git add -A` en scope compartido | Arrastra cambios de otra sub-tarea al commit | `git add <paths-específicos>` |
|
||||
| Lanzar kitty para un fan-out trivial | Caro y sin supervisión que aporte | Agent tool directo (`fn-constructor`, `Explore`, …) |
|
||||
| Hacer tú la feature "porque es rápido" | Pierdes el sentido del modo; el humano no lo ve evolucionar | Descompón y lanza un secundario |
|
||||
| Lanzar sin `--dangerously-skip-permissions` | El secundario se atasca pidiendo permiso en cada Bash | Siempre `--dangerously-skip-permissions` (riesgo asumido) |
|
||||
| Mergear desde el dir del secundario | Master suele estar en el working tree principal; colisión de HEAD | Mergear desde `~/fn_registry` |
|
||||
|
||||
## Funciones del registry que usa este modo (grupo `orchestration`)
|
||||
|
||||
| Función | Para qué |
|
||||
|---|---|
|
||||
| `launch_claude_agent_kitty_bash_infra` | Lanzar un secundario en kitty con prompt autónomo + `--dangerously-skip-permissions` |
|
||||
| `list_claude_agents_bash_infra` | Listar la flota de Claudes vivos (PID, sessionId, cwd, status, kitty) para seguirla |
|
||||
| `reboot_all_claudes_bash_infra` | Reiniciar/parar la flota retomando sesiones; `--exclude-current` para no tocarte |
|
||||
|
||||
## Ejemplo end-to-end
|
||||
|
||||
Tarea grande: *"añade un endpoint `/api/health` al backend de la app `kanban` y, en paralelo,
|
||||
documenta el grupo de capacidad `deploy` en `docs/capabilities/deploy.md`"*. Dos piezas
|
||||
independientes: una toca el sub-repo `apps/kanban` (su propio `.git`), la otra toca el repo
|
||||
padre `fn_registry` (docs). Aislamiento natural distinto para cada una.
|
||||
|
||||
```bash
|
||||
# 1. Descomponer → 2 secundarios independientes:
|
||||
# A) health endpoint → sub-repo apps/kanban (aislamiento (a))
|
||||
# B) doc capability → worktree del padre (aislamiento (b))
|
||||
|
||||
# 2. Preparar aislamiento de B (worktree del padre; A ya está aislado por su sub-repo):
|
||||
git -C ~/fn_registry worktree add /tmp/orq_capdoc -b orq/cap-deploy master
|
||||
|
||||
# 3. Escribir los prompts autónomos (autocontenidos, con reglas de aislamiento):
|
||||
# /tmp/orq_health.md → "trabaja en apps/kanban (sub-repo propio), rama issue/health,
|
||||
# commits atómicos de tus paths, push al terminar, report en reports/. No toques el
|
||||
# repo padre. Reporta tu progreso en esta terminal."
|
||||
# /tmp/orq_capdoc.md → "trabaja SOLO en /tmp/orq_capdoc (worktree), rama orq/cap-deploy,
|
||||
# toca solo docs/capabilities/deploy.md, git add de ese path, push al terminar, report
|
||||
# en reports/. No toques ~/fn_registry. Reporta tu progreso en esta terminal."
|
||||
|
||||
# 4. Lanzar ambos secundarios (cada uno su kitty, su dir aislado):
|
||||
./fn run launch_claude_agent_kitty "kanban · health endpoint" \
|
||||
~/fn_registry/apps/kanban /tmp/orq_health.md
|
||||
./fn run launch_claude_agent_kitty "fn_registry · doc deploy" \
|
||||
/tmp/orq_capdoc /tmp/orq_capdoc.md
|
||||
|
||||
# 5. Seguir la flota (cada turno):
|
||||
./fn run list_claude_agents
|
||||
# → tabla con los 2 secundarios vivos (PID, cwd, sessionId, status) + tu SELF.
|
||||
# Lee /tmp/orq_*_kitty.log para el arranque; cuando terminen, lee sus reports/.
|
||||
|
||||
# 7. Integrar (desde el working tree principal):
|
||||
git -C ~/fn_registry/apps/kanban merge --no-ff issue/health # sub-repo de la app
|
||||
git -C ~/fn_registry merge --no-ff orq/cap-deploy # repo padre (la doc)
|
||||
git -C ~/fn_registry worktree remove /tmp/orq_capdoc # limpiar worktree
|
||||
|
||||
# Resumen al humano: A integrado (endpoint + test verde), B integrado (doc),
|
||||
# flota vacía. Tarea grande hecha.
|
||||
```
|
||||
|
||||
## Salida del modo
|
||||
|
||||
Cuando el humano escriba `salir orquestador` o `fin orquestador`, cierra con un resumen de la
|
||||
flota: secundarios lanzados, cuáles terminaron e integraste, cuáles siguen vivos (con su kitty
|
||||
para que el humano decida), y los reports generados. Si quedan secundarios vivos, recuérdale que
|
||||
`list_claude_agents` los lista y que para pararlos es kill por PID exacto, nunca `pkill`.
|
||||
|
||||
## Relación con otras reglas
|
||||
|
||||
- `.claude/rules/autonomous_loop.md` — `fn-orquestador` (Agent tool, sandbox no-interactivo). Es
|
||||
lo que este modo **no** es; tenlas claras separadas.
|
||||
- `.claude/rules/apps_subrepo.md` — apps/analyses/projects son sub-repos Gitea (`apps/*`
|
||||
gitignored): el aislamiento natural (opción (a)) y el gotcha de `git init` antes de limpiar un
|
||||
worktree con una app nueva dentro.
|
||||
- `.claude/rules/reports.md` + `.claude/rules/dod_quality.md` — qué entrega cada secundario:
|
||||
report con evidencia ejecutable + gaps.
|
||||
- `.claude/rules/delegation.md` + `.claude/rules/registry_calls.md` — los secundarios siguen
|
||||
registry-first y delegan a `fn-constructor` igual que tú.
|
||||
- Memorias: `lanzar-agentes-skip-permissions`, `multi-agent-git-race-same-repo`,
|
||||
`claude-session-pid-mapping`, `prefiere-kitty-terminal`.
|
||||
@@ -21,7 +21,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
|
||||
| 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas |
|
||||
| 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) |
|
||||
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
|
||||
| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). El padre NUNCA trackea contenido de artefactos hijos (solo `.gitkeep`); nada de `git add -f` sobre apps/analysis/projects o deja el padre dirty. `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo |
|
||||
| 18 | [uses_functions.md](uses_functions.md) | Convencion de uses_functions para C++: el .md del consumidor declara las dependencias |
|
||||
| 19 | [cpp_apps.md](cpp_apps.md) | Estandarizacion de apps C++: estructura, CMake, app.md, sub-repo, runtime — apunta a cpp/PATTERNS.md y cpp/DESIGN_SYSTEM.md como autoritativas |
|
||||
| 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) |
|
||||
@@ -39,3 +39,6 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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. |
|
||||
| 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. |
|
||||
| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/<p>/reports/`. Convencion + plantilla. ADR 0006. |
|
||||
| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. |
|
||||
|
||||
@@ -45,6 +45,36 @@ Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_rep
|
||||
|
||||
Todo lo demas (codigo de la app + app.md + appicon + service unit + tests propios de la app) vive en `apps/<name>/.git` independiente.
|
||||
|
||||
### REGLA DURA: el repo padre NUNCA trackea contenido de artefactos hijos
|
||||
|
||||
El repo padre `fn_registry` solo versiona codigo del registry (`functions/`, `types/`, `registry/`, `cmd/`, `docs/`, `.claude/`, `dev/`, `migrations/`, y el framework/functions/vendor de `cpp/`). NUNCA debe trackear el contenido de un artefacto hijo:
|
||||
|
||||
- apps: `apps/*`, `cpp/apps/*`, `projects/*/apps/*`
|
||||
- analyses: `analysis/*`, `projects/*/analysis/*`
|
||||
- projects: `projects/*`
|
||||
|
||||
Cada artefacto es un sub-repo Gitea independiente con su propio `.git`; su contenido completo (codigo, `app.md`, `analysis.md`, `appicon.*`, binarios, frontend, `local_files/`, tests propios) vive SOLO en ese sub-repo. `fn index` lee los `.md` de registro directamente del disco — no necesitan estar en el git del padre. Lo unico que el padre versiona dentro de esos arboles son los marcadores `.gitkeep` (mantienen `apps/` y `analysis/` presentes cuando estan vacios) y, en `projects/`, los `project.md` template si los hubiera.
|
||||
|
||||
**Como se rompe (sintoma = repo padre permanentemente dirty):** un `git add -f apps/<x>/...` (forzado, saltandose el `.gitignore`) o un commit que mete contenido del hijo al padre. Como el archivo ya queda en el indice, el `.gitignore` NO lo vuelve a ignorar y aparece para siempre en `git status` del padre como modificado cada vez que el sub-repo cambia (doble-tracking). Caso real (2026-06-03): `apps/dag_engine/` (31 archivos: Go + frontend + app.md) y `apps/shaders_lab/` (app.md + un binario `.exe` de 23 MB) quedaron forzados al indice del padre y lo dejaban dirty en cada cambio del sub-repo.
|
||||
|
||||
**Auditoria (cero salida = sano):**
|
||||
|
||||
```bash
|
||||
git ls-files 'apps/*' 'analysis/*' 'projects/*/apps/*' 'projects/*/analysis/*' 'cpp/apps/*' \
|
||||
| grep -vE '(^|/)\.gitkeep$'
|
||||
```
|
||||
|
||||
**Fix si aparece contenido trackeado:**
|
||||
|
||||
```bash
|
||||
# --cached SIEMPRE: saca del indice del padre sin borrar el working tree.
|
||||
# El codigo sigue a salvo en el .git del sub-repo.
|
||||
git rm -r --cached apps/<x>
|
||||
git commit -m "chore: untrack contenido del artefacto <x> (es sub-repo Gitea)"
|
||||
```
|
||||
|
||||
NUNCA `git rm` sin `--cached` (borraria el working tree del sub-repo). **Prevencion:** jamas usar `git add -f` sobre paths de artefactos; las reglas `apps/*/`, `analysis/*/`, `projects/*/` del `.gitignore` ya cubren el caso por defecto y solo un force las salta.
|
||||
|
||||
### Sintomas de la perdida
|
||||
|
||||
Si limpias el worktree y luego corres `ls apps/<name>/`, devuelve "No such file or directory" pese a que el issue aparece cerrado en `dev/issues/completed/`. **Patron** = scaffold sin sub-repo init = trabajo perdido.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Artefactos: termino colectivo
|
||||
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez.
|
||||
**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez.
|
||||
|
||||
Tipos de artefacto:
|
||||
|
||||
@@ -11,6 +11,7 @@ Tipos de artefacto:
|
||||
| **vault** | `projects/<p>/vaults/<v>` (symlink) | tabla `vaults` | no (datos fuera del repo) |
|
||||
| **project** | `projects/<p>/` | tabla `projects` | no (vive dentro de fn_registry) |
|
||||
| **playground** | `<artefacto_padre>/playground/` | NO se indexa | no (vive dentro del padre) |
|
||||
| **report** | `reports/`, `projects/<p>/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) |
|
||||
|
||||
Caracteristicas comunes de los artefactos:
|
||||
- NO son codigo reutilizable. La reutilizacion vive en `functions/`.
|
||||
@@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos:
|
||||
- `pc_locations` los unifica via `entity_type` (app, analysis, project, vault).
|
||||
- Pueden importar funciones del registry; el registry NUNCA importa de un artefacto.
|
||||
|
||||
**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/<p>/reports/`.
|
||||
|
||||
### Cuando usar el termino
|
||||
|
||||
Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos:
|
||||
|
||||
@@ -131,7 +131,7 @@ El `if(EXISTS ...)` hace el registro tolerante a apps no clonadas (cada app es s
|
||||
### 6. Sub-repo Gitea (TBD obligatorio)
|
||||
|
||||
Cada app C++ es su propio repo en `dataforge/<name>` con branch `master`. Esto significa:
|
||||
- El directorio `<app_dir>/` esta en el `.gitignore` de `fn_registry` (excepto `app.md`).
|
||||
- TODO el directorio `<app_dir>/` (incluido `app.md`, `appicon.*`, binarios y `local_files/`) esta en el `.gitignore` de `fn_registry`: el repo padre NUNCA versiona contenido del artefacto. `fn index` lee `app.md` directo del disco, no del git. NO forzar con `git add -f` — deja el padre dirty. Ver la regla dura en `apps_subrepo.md`.
|
||||
- El propio directorio tiene `.git/` apuntando al sub-repo.
|
||||
- TBD obligatorio mientras se desarrolla la app: ver `apps_tbd.md`. Trabajar en `issue/<NNNN>-<slug>` o `quick/<slug>`, mergear a `master` con `--no-ff`.
|
||||
- Sync entre PCs y push/pull se gestionan con `/full-git-push` y `/full-git-pull`.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
## Flow replay: guardar un flujo web como función reproducible
|
||||
|
||||
Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor
|
||||
desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a
|
||||
mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087
|
||||
aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones
|
||||
de un solo paso, no inflando funciones existentes.
|
||||
|
||||
Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con
|
||||
el grupo `web-proxy`; destila y reproduce con `flow-replay`.
|
||||
|
||||
### El patrón: grabar → destilar → reproducir
|
||||
|
||||
1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano,
|
||||
exportas el tramo a HAR (`query_mitm_flows --har`).
|
||||
2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) →
|
||||
`har_extract_calls_py_cybersecurity` (call specs reproducibles).
|
||||
3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro):
|
||||
|
||||
| Nivel | Mecanismo | Cuándo |
|
||||
|---|---|---|
|
||||
| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. |
|
||||
| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. |
|
||||
| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. |
|
||||
|
||||
**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no
|
||||
reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo
|
||||
exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro.
|
||||
|
||||
### Flujo de autoría (cómo guardar una función-acción nueva)
|
||||
|
||||
1. Grabar el flujo y exportar el HAR del tramo.
|
||||
2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el
|
||||
HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción +
|
||||
confirmación), descartando el resto.
|
||||
3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las
|
||||
reglas `extract` para los tokens que una respuesta genera y otro request consume.
|
||||
4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1.
|
||||
5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada
|
||||
con verbo (`reboot_vps_server_<panel>`, `login_<panel>`, `export_<panel>_report`) que
|
||||
internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros
|
||||
del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el
|
||||
dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno.
|
||||
|
||||
### Reglas duras de seguridad
|
||||
|
||||
- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no
|
||||
indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por
|
||||
`{{param}}`.
|
||||
- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción.
|
||||
- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar,
|
||||
pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag
|
||||
explícito (`confirm=True` / `--yes`) antes de disparar.
|
||||
- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción
|
||||
JSON es dot-path simple, no JSONPath completo.
|
||||
|
||||
### Anti-patrones
|
||||
|
||||
| Anti-patrón | Por qué es malo | Sustituir por |
|
||||
|---|---|---|
|
||||
| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción |
|
||||
| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` |
|
||||
| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla |
|
||||
| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault |
|
||||
| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored |
|
||||
| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito |
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a
|
||||
`fn-constructor`, no se escribe inline.
|
||||
- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones
|
||||
repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación.
|
||||
- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP /
|
||||
`fn run` / heredoc que importa).
|
||||
- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0.
|
||||
@@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta
|
||||
|
||||
Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente.
|
||||
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate`
|
||||
|
||||
### Excepciones
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
## Invocación de LLM: SIEMPRE `ask_llm`, NUNCA `claude -p`
|
||||
|
||||
**REGLA DURA.** Para ejecutar un modelo LLM desde cualquier código del ecosistema (scripts, heredocs, apps, pipelines, agentes), usa el grupo `claude-direct` — empezando por `ask_llm_py_core`. **NUNCA** uses `claude -p` ni lances el binario `claude` como subproceso para obtener una respuesta del modelo.
|
||||
|
||||
### Por qué
|
||||
|
||||
| | `claude -p` | `ask_llm` / `claude-direct` |
|
||||
|---|---|---|
|
||||
| Mecanismo | Lanza Claude Code entero (proceso `claude`) | Habla directo a `api.anthropic.com/v1/messages` |
|
||||
| Arranque | ~7-15s (carga MCP + `CLAUDE.md` ~100k tokens) | **0 — request HTTP directa** |
|
||||
| Latencia/msg | ~9-15s | **~2.5s** |
|
||||
| Coste | Alto (re-carga contexto cada vez) | Mínimo (solo tu prompt) |
|
||||
| Tools | Las de Claude Code (no controlables) | **Las que tú defines** (`run_claude_tool_loop`) |
|
||||
| Streaming | indirecto | nativo (`stream_anthropic_messages`) |
|
||||
|
||||
`claude -p` es lento, caro y arranca todo Claude Code para una completion. `ask_llm` es la API directa: arranque 0, rápido, con tus propias tools. Usa el token OAuth que Claude Code ya guarda en `~/.claude/.credentials.json`.
|
||||
|
||||
### Cómo (según el caso)
|
||||
|
||||
| Caso | Usa |
|
||||
|---|---|
|
||||
| Pregunta/chat one-shot | `fn run ask_llm "..."` o `from core.ask_llm import ask_llm` |
|
||||
| Streaming de eventos crudos (text/tool_use deltas) | `stream_anthropic_messages_py_core` |
|
||||
| Agente con TUS tools (tool-use loop) | `run_claude_tool_loop_py_core` (defines `tools` + `dispatch`) |
|
||||
| Token OAuth | `load_claude_oauth_token_py_core` (automático dentro de las anteriores) |
|
||||
| Distribuir fuera del registry | `apps/llm_cli/llm.py` (versión standalone autocontenida) |
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from core.ask_llm import ask_llm
|
||||
respuesta = ask_llm("resume esto en 3 lineas: ...", model="claude-haiku-4-5-20251001", echo=False)
|
||||
```
|
||||
|
||||
### Legacy
|
||||
|
||||
`claude_stream_go_core` (lanza `claude -p --output-format stream-json`) es el **camino antiguo**. No usarlo en código nuevo — preferir las funciones `claude-direct`. Queda solo para compatibilidad de consumidores existentes.
|
||||
|
||||
### Excepción acotada
|
||||
|
||||
Si una tarea necesita **genuinamente las capacidades de Claude Code** (sus tools nativas, los MCP del repo, plan mode, el contexto del proyecto) y no basta con el modelo + tus propias tools via `run_claude_tool_loop`, entonces NO es una "invocación LLM" simple: documenta por qué en el código. El **default sin excepción es `ask_llm`**.
|
||||
|
||||
### Telemetría / auditoría
|
||||
|
||||
Un `claude -p` o un `subprocess(["claude", "-p", ...])` en código nuevo es un antipatrón auditable: sustituir por `ask_llm` / `claude-direct`. Buscar usos: `grep -rn 'claude -p' --include='*.py' --include='*.sh' --include='*.go'`.
|
||||
|
||||
### Relación con otras reglas
|
||||
|
||||
- [[registry_calls]] — patrones canónicos de invocación de funciones; esta regla fija el patrón para la sub-tarea "invocar un LLM".
|
||||
- [[registry_first]] — reusar antes que reescribir; `ask_llm` es la función reutilizable para LLM.
|
||||
@@ -28,6 +28,23 @@ projects/{nombre}/
|
||||
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
||||
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
|
||||
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
|
||||
|
||||
### Cada project es su propio repo Gitea (sub-repo)
|
||||
|
||||
Desde 2026-06-05 cada `projects/<nombre>/` es un **repo Gitea independiente** `dataforge/<nombre>` (branch `master`), igual que las apps y los analyses. El repo del project versiona **solo las docs de nivel-project** (`project.md`, `CONVENTIONS.md` y demás `.md`/`.claude/` propios del project). El contenido de los hijos NO se versiona aquí: cada `apps/<app>/` y cada `analysis/<a>/` es su propio sub-repo Gitea y queda excluido por el `.gitignore` del project:
|
||||
|
||||
```gitignore
|
||||
apps/*/
|
||||
analysis/*/
|
||||
vaults/*
|
||||
!vaults/.gitkeep
|
||||
```
|
||||
|
||||
- **Crear el repo del project**: `ensure_repo_synced_bash_infra projects/<nombre> dataforge <nombre> master "init: project <nombre>"` (necesita `GITEA_URL` + `GITEA_TOKEN`; el token está en `pass gitea/dataforge-git-token`). Crear el `.gitignore` de arriba ANTES, para no trackear el contenido de los sub-repos hijos.
|
||||
- **Push/pull**: `/full-git-push` y `/full-git-pull` ya lo manejan automáticamente — `discover_git_repos_bash_infra` descubre cualquier `.git` bajo `fn_registry`, incluidos los projects.
|
||||
- **`repo_url`** en `project.md` apunta al repo del project; los `repo_url` de cada app viven en su `app.md`. Así el project "referencia" sus sub-repos sin git submodules (KISS).
|
||||
- El repo padre `fn_registry` sigue ignorando `projects/*/` entero (regla `apps_subrepo.md`): nunca trackea contenido de projects.
|
||||
- Estado actual: `dataforge/web_scraping`, `dataforge/fn_monitoring`, `dataforge/message_bus`.
|
||||
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||
|
||||
### Raiz vs proyecto
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
## Reports: reportes de trabajo como artefacto local
|
||||
|
||||
Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo.
|
||||
|
||||
Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia:
|
||||
|
||||
- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**.
|
||||
- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`.
|
||||
- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`.
|
||||
|
||||
### Qué NO es un report
|
||||
|
||||
| Es | Va a |
|
||||
|---|---|
|
||||
| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) |
|
||||
| Norma operativa / convención | `.claude/rules/` (versionado) |
|
||||
| Bitácora cronológica libre | `docs/diary/` (versionado) |
|
||||
| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** |
|
||||
|
||||
Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia.
|
||||
|
||||
### Ubicación
|
||||
|
||||
Como cualquier artefacto, un report puede vivir en dos sitios:
|
||||
|
||||
| Ubicación | Para qué |
|
||||
|---|---|
|
||||
| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto |
|
||||
| `projects/<p>/reports/` | Reportes del trabajo de un proyecto concreto |
|
||||
|
||||
Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …).
|
||||
|
||||
### Convención de nombre
|
||||
|
||||
```
|
||||
NNNN-YYYY-MM-DD-slug-corto.md
|
||||
```
|
||||
|
||||
- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003").
|
||||
- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA).
|
||||
- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`.
|
||||
|
||||
### Plantilla mínima
|
||||
|
||||
```markdown
|
||||
# Report NNNN — Título
|
||||
|
||||
- **Fecha:** DD/MM/AAAA
|
||||
- **Autor:** (agente/humano)
|
||||
- **Ámbito:** (dominio/app/módulo tocado)
|
||||
- **Estado:** done | parcial | bloqueado
|
||||
|
||||
## Resumen
|
||||
Qué se hizo y el resultado, en 2-4 líneas.
|
||||
|
||||
## Cambios
|
||||
Tabla o lista de lo tocado/creado, con el porqué.
|
||||
|
||||
## Verificación
|
||||
Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia.
|
||||
|
||||
## Gaps / pendientes
|
||||
Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.).
|
||||
```
|
||||
|
||||
### Reglas
|
||||
|
||||
- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit.
|
||||
- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`.
|
||||
- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir.
|
||||
- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio.
|
||||
|
||||
### Relación con otras reglas y ADRs
|
||||
|
||||
- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio).
|
||||
- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito.
|
||||
- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps.
|
||||
- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`.
|
||||
@@ -1,11 +1,28 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(CGO_ENABLED=1 go test *)",
|
||||
"Bash(sqlite3 *)",
|
||||
"Read(//home/enmanuel/.claude/**)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"registry",
|
||||
"jupyter"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh" },
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh" }
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_mcp.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_fn_match.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -13,21 +30,33 @@
|
||||
{
|
||||
"matcher": "Bash|Edit|Write|MultiEdit|mcp__registry__.*",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh" }
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_call_monitor.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|mcp__registry__fn_create_function",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh" }
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capability_tag_gate.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh" },
|
||||
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh" }
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_capabilities_inject.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/hook_registry_first_reminder.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+9
-3
@@ -46,6 +46,13 @@ projects/*/
|
||||
vaults/*/
|
||||
!vaults/vault.yaml
|
||||
|
||||
# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a
|
||||
# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en
|
||||
# reports/ (raíz) o projects/<p>/reports/. Convención: .claude/rules/reports.md
|
||||
reports/*
|
||||
!reports/.gitkeep
|
||||
projects/*/reports/
|
||||
|
||||
# Node / pnpm
|
||||
**/node_modules/
|
||||
|
||||
@@ -67,8 +74,8 @@ worktrees/
|
||||
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||
temp/
|
||||
|
||||
# C++ build artifacts
|
||||
cpp/build/
|
||||
# C++ build artifacts (build/, build-tests/, build-windows/, etc.)
|
||||
cpp/build*/
|
||||
/build/
|
||||
|
||||
# OS
|
||||
@@ -81,7 +88,6 @@ Thumbs.db
|
||||
broken_paths.txt
|
||||
imgui.ini
|
||||
prompts/
|
||||
kotlin/functions/ui/
|
||||
|
||||
# Module versioning auto-generated headers (written by `fn index`, issue 0097)
|
||||
**/version_generated.h
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"0ea5e69b-9607-4f11-b740-005e835faef6": {
|
||||
"version": "2.4.0",
|
||||
"created_at": "2026-06-03T17:52:16.077873+00:00",
|
||||
"document_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: apply_chromium_cdp_flag
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]"
|
||||
description: "Gestiona de forma idempotente el fragmento /etc/chromium.d/cdp que activa Chrome DevTools Protocol global en todo chromium que el usuario lance en el equipo. Escribe, actualiza o borra el fragmento con backup automático."
|
||||
tags: [navegator, chromium, cdp, devtools, browser, automation, infra]
|
||||
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 TCP donde Chromium escuchará conexiones CDP. Default 9222."
|
||||
- name: "--network"
|
||||
desc: "Si se pasa, añade --remote-debugging-address=0.0.0.0 (accesible desde la red local). Por defecto solo loopback (127.0.0.1). Imprime advertencia de seguridad."
|
||||
- name: "--fragment-path <path>"
|
||||
desc: "Ruta del fragmento a escribir/borrar. Default /etc/chromium.d/cdp."
|
||||
- name: "--remove"
|
||||
desc: "Borra el fragmento (desactiva CDP global). Idempotente: si no existe, no-op."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el fragmento que se escribiría sin tocar nada. No requiere sudo."
|
||||
output: "Sale 0 en éxito (aplicado, ya-aplicado, o eliminado). Sale != 0 en error con mensaje a stderr. En caso de actualización imprime ruta del backup creado."
|
||||
file_path: "bash/functions/browser/apply_chromium_cdp_flag.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Activar CDP global en loopback puerto 9222 (proyecto web_scraping, regla 8)
|
||||
source bash/functions/browser/apply_chromium_cdp_flag.sh
|
||||
apply_chromium_cdp_flag
|
||||
|
||||
# Previsualizar el fragmento sin escribir nada (no requiere sudo)
|
||||
apply_chromium_cdp_flag --port 9222 --dry-run
|
||||
|
||||
# Puerto alternativo (para correr en paralelo al navegador del usuario)
|
||||
apply_chromium_cdp_flag --port 9333
|
||||
|
||||
# Activar expuesto a la red local (RIESGO: cualquier host de la LAN puede controlar el navegador)
|
||||
apply_chromium_cdp_flag --port 9222 --network
|
||||
|
||||
# Desactivar CDP global
|
||||
apply_chromium_cdp_flag --remove
|
||||
|
||||
# Ruta personalizada (útil en pruebas o entornos chroot)
|
||||
apply_chromium_cdp_flag --port 9222 --fragment-path /tmp/test_cdp_fragment --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo para controlar el chromium diario del usuario vía CDP (primer setup del proyecto `web_scraping`, regla 8). Al cambiar el puerto CDP del sistema. Al desactivar esa capacidad antes de prestar o formatear el equipo. Sustituye el paso manual "crear `/etc/chromium.d/cdp` con sudo" documentado en `CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere sudo** para escribir bajo `/etc/`. En este equipo usar `pass show claude/sudo | sudo -S apply_chromium_cdp_flag` o ejecutar como root.
|
||||
- **`--network` (0.0.0.0) es un riesgo de seguridad serio**: cualquier máquina en la red local puede conectarse al puerto CDP y controlar Chromium completamente (leer cookies, sesiones, inyectar JavaScript). Solo usar en entornos de red aislados o laboratorios.
|
||||
- **El chromium ya abierto antes del cambio no hereda el flag** hasta que se reinicie. El fragmento solo se aplica en el próximo arranque de `/usr/bin/chromium`.
|
||||
- **Dos procesos chromium no pueden compartir el mismo puerto**. Si el usuario ya tiene un chromium con CDP en 9222, la automatización dedicada debe arrancar con `chrome_launch_go_browser` en otro puerto (ej. 9333) o usar `--port 9333` en esta función.
|
||||
- **Idempotente**: si el fragmento ya existe con contenido idéntico, la función sale 0 sin tocar nada ni crear backup.
|
||||
- **Backup automático**: al sobreescribir, crea `<path>.bak.YYYYMMDD`. Si ya existe un backup del mismo día, no lo sobreescribe (el primero del día se preserva).
|
||||
- **Validación post-escritura**: tras escribir, verifica con `grep` que la línea `export CHROMIUM_FLAGS` con el puerto correcto quedó en el archivo. Si falla, restaura el backup y sale con error.
|
||||
- Ver `projects/web_scraping/CHROMIUM_SYSTEM.md` para el contexto completo del sistema CDP de este equipo.
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_cdp_flag — gestiona el fragmento /etc/chromium.d/cdp que activa CDP global.
|
||||
#
|
||||
# Uso:
|
||||
# apply_chromium_cdp_flag [--port N] [--network] [--fragment-path <path>] [--remove] [--dry-run]
|
||||
|
||||
apply_chromium_cdp_flag() {
|
||||
local port=9222
|
||||
local network=0
|
||||
local fragment_path="/etc/chromium.d/cdp"
|
||||
local remove=0
|
||||
local dry_run=0
|
||||
|
||||
# Parseo de argumentos
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
port="$2"
|
||||
shift 2
|
||||
;;
|
||||
--network)
|
||||
network=1
|
||||
shift
|
||||
;;
|
||||
--fragment-path)
|
||||
fragment_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remove)
|
||||
remove=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_cdp_flag: argumento desconocido: $1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validación del puerto
|
||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
|
||||
echo "apply_chromium_cdp_flag: puerto inválido: $port (debe ser 1-65535)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Construcción del contenido del fragmento
|
||||
local flags_line
|
||||
if (( network )); then
|
||||
echo "ADVERTENCIA DE SEGURIDAD: --network activa --remote-debugging-address=0.0.0.0." >&2
|
||||
echo "El navegador quedará expuesto a toda la red local. Cualquier host en la red" >&2
|
||||
echo "podrá controlar Chromium remotamente y leer todas las sesiones abiertas." >&2
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=* --remote-debugging-address=0.0.0.0"'
|
||||
else
|
||||
flags_line='export CHROMIUM_FLAGS="$CHROMIUM_FLAGS --remote-debugging-port='"${port}"' --remote-allow-origins=*"'
|
||||
fi
|
||||
|
||||
local mode_label
|
||||
if (( network )); then
|
||||
mode_label="network (0.0.0.0)"
|
||||
else
|
||||
mode_label="loopback (127.0.0.1)"
|
||||
fi
|
||||
|
||||
local fragment_content
|
||||
fragment_content="# CDP global para automatizacion del navegador del usuario (proyecto web_scraping, regla 8).
|
||||
# Bind ${mode_label} por defecto: el puerto solo
|
||||
# es accesible desde 127.0.0.1, no desde la red.
|
||||
${flags_line}"
|
||||
|
||||
# Modo --dry-run: solo mostrar y salir
|
||||
if (( dry_run )); then
|
||||
echo "--- dry-run: fragmento que se escribiría en ${fragment_path} ---"
|
||||
echo "${fragment_content}"
|
||||
echo "--- fin dry-run ---"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Modo --remove
|
||||
if (( remove )); then
|
||||
if [[ ! -e "$fragment_path" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ${fragment_path} no existe, nada que borrar."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
rm "$fragment_path"
|
||||
else
|
||||
sudo rm "$fragment_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo borrar ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
echo "apply_chromium_cdp_flag: fragmento eliminado (backup: ${backup_path})"
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no lo hereda hasta reiniciarlo."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Idempotencia: comparar con contenido actual
|
||||
if [[ -f "$fragment_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$fragment_path" 2>/dev/null)
|
||||
if [[ "$current_content" == "$fragment_content" ]]; then
|
||||
echo "apply_chromium_cdp_flag: ya aplicado, sin cambios (${fragment_path})."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Crear directorio si falta
|
||||
local fragment_dir
|
||||
fragment_dir=$(dirname "$fragment_path")
|
||||
if [[ ! -d "$fragment_dir" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
mkdir -p "$fragment_dir"
|
||||
else
|
||||
sudo mkdir -p "$fragment_dir" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear ${fragment_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Backup si ya existe y difiere
|
||||
if [[ -e "$fragment_path" ]]; then
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ ! -e "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$fragment_path" "$backup_path"
|
||||
else
|
||||
sudo cp "$fragment_path" "$backup_path" || {
|
||||
echo "apply_chromium_cdp_flag: no se pudo crear backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup creado en ${backup_path}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Escribir fragmento
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
printf '%s\n' "$fragment_content" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$tmpfile" "$fragment_path"
|
||||
chmod 644 "$fragment_path"
|
||||
else
|
||||
sudo cp "$tmpfile" "$fragment_path" || {
|
||||
rm -f "$tmpfile"
|
||||
echo "apply_chromium_cdp_flag: no se pudo escribir ${fragment_path}" >&2
|
||||
return 1
|
||||
}
|
||||
sudo chmod 644 "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# Validación post-escritura
|
||||
local expected_line="--remote-debugging-port=${port}"
|
||||
if ! grep -qF "$expected_line" "$fragment_path" 2>/dev/null; then
|
||||
echo "apply_chromium_cdp_flag: validación fallida — la línea export no apareció en ${fragment_path}." >&2
|
||||
# Restaurar backup si existe
|
||||
local backup_path="${fragment_path}.bak.$(date +%Y%m%d)"
|
||||
if [[ -f "$backup_path" ]]; then
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
cp "$backup_path" "$fragment_path"
|
||||
else
|
||||
sudo cp "$backup_path" "$fragment_path" 2>/dev/null || true
|
||||
fi
|
||||
echo "apply_chromium_cdp_flag: backup restaurado desde ${backup_path}" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Resumen final
|
||||
echo "apply_chromium_cdp_flag: CDP global activado correctamente."
|
||||
echo " Fragmento : ${fragment_path}"
|
||||
echo " Puerto : ${port}"
|
||||
echo " Modo : ${mode_label}"
|
||||
echo ""
|
||||
echo "Nota: el chromium ya abierto antes de este cambio no hereda el flag hasta reiniciarlo."
|
||||
echo "Nota: dos procesos chromium no pueden compartir el mismo puerto; usa --port <otro> para"
|
||||
echo " automatización dedicada que corra en paralelo al navegador del usuario."
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_cdp_flag "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: apply_chromium_extension_policy
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "apply_chromium_extension_policy [--keep <ext_id[=update_url]>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]"
|
||||
description: "Escribe de forma idempotente la política managed de Chromium combinando ExtensionInstallForcelist (force-instala la whitelist --keep) y ExtensionInstallBlocklist (bloquea y desinstala la blocklist --block). No usa el comodín \"*\" blocked, por lo que NO afecta a las extensiones unpacked cargadas con --load-extension. Guarda backup fuera del directorio managed/ (que Chromium lee entero). Requiere sudo para escribir en /etc; en --dry-run no toca el sistema."
|
||||
tags: [chromium, extensions, policy, browser, navegator, managed-policy, idempotent]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--keep <ext_id[=update_url]>"
|
||||
desc: "ID de extensión a force-instalar (repetible). Va a ExtensionInstallForcelist. Forma simple '<id>' usa el update_url por defecto (Web Store). Forma '<id>=<update_url>' fuerza una extensión self-hosted: por ejemplo '<id>=file:///home/u/.web_proxy/update.xml' instala un .crx local empaquetado bajo managed policy (necesario porque --load-extension queda desactivado cuando hay managed policy). Ejemplo Web Store: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)."
|
||||
- name: "--block <ext_id>"
|
||||
desc: "ID de extensión a bloquear y desinstalar en cualquier perfil (repetible). Va a ExtensionInstallBlocklist. Solo afecta a los IDs listados; el resto de extensiones no se toca."
|
||||
- name: "--policy-path <path>"
|
||||
desc: "Ruta del JSON de managed policy. Default: /etc/chromium/policies/managed/extensions.json."
|
||||
- name: "--update-url <url>"
|
||||
desc: "URL del servicio de actualización de extensiones. Default: https://clients2.google.com/service/update2/crx."
|
||||
- name: "--dry-run"
|
||||
desc: "Imprime el JSON que se escribiría sin tocar el sistema (no requiere sudo)."
|
||||
output: "Escribe el JSON de política en policy-path y emite a stdout un resumen: extensiones forzadas, bloqueadas, ruta, backup creado y recordatorio de reinicio de Chromium. Sale 0 si la política se aplicó o ya estaba vigente. Sale != 0 en error. Requiere al menos un --keep o --block."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/apply_chromium_extension_policy.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dejar el perfil con solo uBlock Origin Lite: forzar uBlock + bloquear las 3 que estorban
|
||||
# al scraping (Dark Reader, NoScript, OneTab). Proyecto web_scraping, regla 9.
|
||||
source bash/functions/browser/apply_chromium_extension_policy.sh
|
||||
apply_chromium_extension_policy \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--block eimadpbcbfnmbkopoojfekhnkhdbieeh \
|
||||
--block doojmbjmlfjjnbmnoijecmcbfeoakpjm \
|
||||
--block chphlpgkkbolifaimnlloiipkdnihall
|
||||
|
||||
# Previsualizar sin tocar el sistema (sin sudo)
|
||||
apply_chromium_extension_policy --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Ejecutar como root para el sudo no interactivo de este equipo
|
||||
pass show claude/sudo | sudo -S bash bash/functions/browser/apply_chromium_extension_policy.sh \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh --block eimadpbcbfnmbkopoojfekhnkhdbieeh
|
||||
```
|
||||
|
||||
La policy por sí sola evita la reinstalación pero NO desinstala lo ya presente en un perfil concreto:
|
||||
combínala con `clean_chrome_profile_extensions_bash_browser` (con Chromium cerrado) para purgar del
|
||||
disco las extensiones ya instaladas.
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al preparar un PC nuevo o cambiar qué extensiones de Chrome Web Store deben estar (o no estar) en
|
||||
cualquier perfil de Chromium del equipo. Reemplaza el paso manual de editar el JSON de policy con
|
||||
sudo. `--keep` fuerza y fija las imprescindibles; `--block` elimina las molestas sin tocar el resto.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El backup NUNCA va dentro de `managed/`** (lo gestiona la función, pero es la lección clave): Chromium
|
||||
lee **todos** los archivos del directorio `policies/managed/` sin filtrar por extensión de nombre. Un
|
||||
`extensions.json.bak.YYYYMMDD` dentro de `managed/` se mergea con la policy efectiva y **reinyecta** las
|
||||
extensiones del backup (se ven como `location=7` external_policy_download y vuelven aunque las borres).
|
||||
Por eso la función guarda los backups en `policies/policy-backups/`, fuera de `managed/`. Si encuentras
|
||||
backups antiguos dentro de `managed/`, muévelos fuera.
|
||||
- **No usa el comodín `"*": blocked`**: ese modo desinstala todo lo no-whitelist pero también **bloquea las
|
||||
extensiones unpacked** (`--load-extension`), rompiendo cosas como la extensión de captura de `web_proxy`
|
||||
con el error "Loading of unpacked extensions is disabled by the administrator". Esta función bloquea solo
|
||||
los IDs de `--block`.
|
||||
- **`--load-extension` y managed policy son incompatibles en Chromium 137+**: con CUALQUIER managed policy
|
||||
presente, Chromium desactiva `--load-extension` ("disabled by the administrator"). Para cargar una
|
||||
extensión local junto a una managed policy hay que empaquetarla (.crx + update_url) o usar `--proxy-server`
|
||||
directo en el caso de `web_proxy`.
|
||||
- **Requiere sudo** para escribir en `/etc/chromium/policies/managed/`. En este equipo: `pass show claude/sudo | sudo -S <cmd>`.
|
||||
- **Chrome cachea la política en memoria**: cerrar TODOS los Chromium (`pkill -9 chromium`) y relanzar, o `chrome://policy` → "Reload policies".
|
||||
- **Idempotente**: si el archivo ya tiene el mismo contenido, no-op y sale 0.
|
||||
- Referencia del sistema completo: `projects/web_scraping/CHROMIUM_SYSTEM.md`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-05) — `--keep` acepta `<id>=<update_url>` para force-instalar extensiones self-hosted (p.ej. un `.crx` local vía `file://` a un `update.xml`), que es la forma de cargar una extensión propia cuando hay managed policy y `--load-extension` está desactivado.
|
||||
- v1.1.0 (2026-06-05) — añade `--block` (ExtensionInstallBlocklist); reemplaza el modo `ExtensionSettings "*": blocked` (rompía extensiones unpacked) por blocklist específica; mueve los backups fuera de `managed/` (Chromium lee todo el directorio y un `.bak` ahí reinyectaba extensiones).
|
||||
- v1.0.0 (2026-06-05) — baseline: ExtensionInstallForcelist con whitelist `--keep`.
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply_chromium_extension_policy — Escribe de forma idempotente la política managed de Chromium
|
||||
# que fuerza la instalación de una whitelist de extensiones y bloquea (desinstala) una blocklist
|
||||
# concreta, sin tocar el resto. Usa ExtensionInstallForcelist + ExtensionInstallBlocklist.
|
||||
|
||||
apply_chromium_extension_policy() {
|
||||
local policy_path="/etc/chromium/policies/managed/extensions.json"
|
||||
local update_url="https://clients2.google.com/service/update2/crx"
|
||||
local dry_run=0
|
||||
local -a keep_ids=()
|
||||
local -a block_ids=()
|
||||
|
||||
# --- Parseo de argumentos ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --keep requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
keep_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--block)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --block requiere un ID de extensión" >&2
|
||||
return 1
|
||||
fi
|
||||
block_ids+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--policy-path)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --policy-path requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
policy_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--update-url)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "apply_chromium_extension_policy: --update-url requiere un valor" >&2
|
||||
return 1
|
||||
fi
|
||||
update_url="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "apply_chromium_extension_policy: argumento desconocido: $1" >&2
|
||||
echo "Uso: apply_chromium_extension_policy [--keep <ext_id>]... [--block <ext_id>]... [--policy-path <path>] [--update-url <url>] [--dry-run]" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Validar que hay al menos una extensión a forzar o bloquear ---
|
||||
if [[ ${#keep_ids[@]} -eq 0 && ${#block_ids[@]} -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: se requiere al menos un --keep o un --block <ext_id>" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Construir el JSON ---
|
||||
# Dos claves complementarias, ninguna bloquea las extensiones unpacked (--load-extension),
|
||||
# de modo que extensiones locales como la de captura de web_proxy siguen cargando:
|
||||
# 1. ExtensionInstallForcelist: fuerza la instalación de la whitelist (--keep), que además
|
||||
# no se puede desinstalar desde la UI.
|
||||
# 2. ExtensionInstallBlocklist: bloquea Y desinstala las extensiones de la blocklist
|
||||
# (--block) en cualquier perfil. Solo afecta a los IDs listados; el resto no se toca.
|
||||
local forcelist_json="[]" blocklist_json="[]"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for kid in "${keep_ids[@]}"; do
|
||||
# Cada --keep puede ser "<id>" (usa el update_url por defecto, Web Store) o
|
||||
# "<id>=<update_url>" para una extensión self-hosted (p.ej. file:// a un update.xml local).
|
||||
local id="${kid%%=*}" url="$update_url"
|
||||
[[ "$kid" == *=* ]] && url="${kid#*=}"
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id};${url}\""
|
||||
first=0
|
||||
done
|
||||
forcelist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
local entries="" first=1
|
||||
for id in "${block_ids[@]}"; do
|
||||
[[ $first -eq 0 ]] && entries+=","$'\n'
|
||||
entries+=" \"${id}\""
|
||||
first=0
|
||||
done
|
||||
blocklist_json=$(printf '[\n%s\n ]' "$entries")
|
||||
fi
|
||||
|
||||
local new_json
|
||||
new_json=$(cat <<JSONEOF
|
||||
{
|
||||
"ExtensionInstallForcelist": ${forcelist_json},
|
||||
"ExtensionInstallBlocklist": ${blocklist_json}
|
||||
}
|
||||
JSONEOF
|
||||
)
|
||||
|
||||
# --- Modo dry-run ---
|
||||
if [[ $dry_run -eq 1 ]]; then
|
||||
echo "[dry-run] JSON que se escribiría en: ${policy_path}"
|
||||
echo "---"
|
||||
echo "$new_json"
|
||||
echo "---"
|
||||
echo "[dry-run] No se ha modificado el sistema."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# --- Idempotencia: comparar con contenido actual ---
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local current_content
|
||||
current_content=$(cat "$policy_path" 2>/dev/null || true)
|
||||
if [[ "$current_content" == "$new_json" ]]; then
|
||||
echo "apply_chromium_extension_policy: política ya aplicada (sin cambios). Nada que hacer."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Backup del archivo existente ---
|
||||
# CRÍTICO: el backup NUNCA puede vivir dentro del directorio de la policy. Chromium lee TODOS
|
||||
# los archivos del directorio managed/ (sin filtrar por extensión de nombre), así que un
|
||||
# "extensions.json.bak.YYYYMMDD" dentro de managed/ se mergea con la policy efectiva y reinyecta
|
||||
# las extensiones del backup. Por eso el backup se guarda en un directorio hermano (policy-backups)
|
||||
# que chromium no lee.
|
||||
local backup_path=""
|
||||
if [[ -f "$policy_path" ]]; then
|
||||
local date_suffix policy_dir backup_dir
|
||||
date_suffix=$(date +%Y%m%d)
|
||||
policy_dir="$(dirname "$policy_path")"
|
||||
case "$(basename "$policy_dir")" in
|
||||
managed|recommended) backup_dir="$(dirname "$policy_dir")/policy-backups" ;;
|
||||
*) backup_dir="$policy_dir" ;;
|
||||
esac
|
||||
backup_path="${backup_dir}/$(basename "$policy_path").bak.${date_suffix}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then sudo mkdir -p "$backup_dir" 2>/dev/null; else mkdir -p "$backup_dir"; fi
|
||||
fi
|
||||
if [[ ! -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando backup → ${backup_path}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$policy_path" "$backup_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el backup en ${backup_path}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
echo "apply_chromium_extension_policy: backup del día ya existe (${backup_path}), se omite."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Crear directorio padre si no existe ---
|
||||
local policy_dir
|
||||
policy_dir=$(dirname "$policy_path")
|
||||
if [[ ! -d "$policy_dir" ]]; then
|
||||
echo "apply_chromium_extension_policy: creando directorio ${policy_dir}"
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
mkdir -p "$policy_dir" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo crear el directorio ${policy_dir}" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Escribir el JSON vía tmpfile + sudo cp ---
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp /tmp/chromium_policy_XXXXXX.json)
|
||||
echo "$new_json" > "$tmpfile"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
else
|
||||
cp "$tmpfile" "$policy_path" || {
|
||||
echo "apply_chromium_extension_policy: no se pudo escribir ${policy_path}" >&2
|
||||
rm -f "$tmpfile"
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo "apply_chromium_extension_policy: restaurando backup tras error..."
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
rm -f "$tmpfile"
|
||||
|
||||
# --- Validar el JSON escrito ---
|
||||
local validation_ok=0
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$policy_path" 2>/dev/null && validation_ok=1
|
||||
elif command -v jq &>/dev/null; then
|
||||
jq . "$policy_path" &>/dev/null && validation_ok=1
|
||||
else
|
||||
validation_ok=1
|
||||
fi
|
||||
|
||||
if [[ $validation_ok -eq 0 ]]; then
|
||||
echo "apply_chromium_extension_policy: el JSON escrito no es válido — restaurando backup" >&2
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
sudo cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
else
|
||||
cp "$backup_path" "$policy_path" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- Resumen final ---
|
||||
echo "apply_chromium_extension_policy: política aplicada correctamente."
|
||||
echo " Ruta : ${policy_path}"
|
||||
if [[ ${#keep_ids[@]} -gt 0 ]]; then
|
||||
echo " Forzadas (${#keep_ids[@]}):"
|
||||
for id in "${keep_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
if [[ ${#block_ids[@]} -gt 0 ]]; then
|
||||
echo " Bloqueadas/desinstaladas (${#block_ids[@]}):"
|
||||
for id in "${block_ids[@]}"; do echo " - ${id}"; done
|
||||
fi
|
||||
echo " Extensiones unpacked (--load-extension, p.ej. web_proxy): NO afectadas."
|
||||
if [[ -n "$backup_path" && -f "$backup_path" ]]; then
|
||||
echo " Backup : ${backup_path}"
|
||||
fi
|
||||
echo ""
|
||||
echo " AVISO: Chromium cachea la politica en memoria. Para que surta efecto:"
|
||||
echo " pkill -9 chromium (cierra TODOS los procesos)"
|
||||
echo " # Luego relanza Chromium. O desde un Chromium abierto:"
|
||||
echo " # chrome://policy → 'Reload policies'"
|
||||
}
|
||||
|
||||
# Auto-ejecución al correr el archivo directo (bash file.sh / fn run). Si se hace `source`,
|
||||
# solo se define la función y no se ejecuta nada.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
apply_chromium_extension_policy "$@"
|
||||
fi
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: backup_chrome_bookmarks
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]... [--backup-dir <dir>] [--dry-run]"
|
||||
description: "Copia byte a byte los archivos Bookmarks de perfiles Chrome/Chromium a un directorio de backup con timestamp ISO. Descubre automáticamente todos los perfiles con Bookmarks si no se especifica ninguno. Preserva el checksum interno del archivo sin parsear ni reserializar el JSON. No requiere que Chromium esté cerrado."
|
||||
tags: [navegator, chromium, bookmarks, backup, browser, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/backup_chrome_bookmarks.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "(obligatorio) Ruta raíz del user-data-dir de Chrome/Chromium. Ej: ~/.config/chromium-cdp"
|
||||
- name: --profile
|
||||
desc: "Nombre de carpeta de perfil a respaldar (repetible). Si no se pasa ninguno se descubren todos los perfiles que contengan un archivo Bookmarks, excluyendo System Profile."
|
||||
- name: --backup-dir
|
||||
desc: "Directorio raíz donde se crearán los backups. Default: ~/.local/share/web_scraping/bookmarks-backups"
|
||||
- name: --dry-run
|
||||
desc: "Muestra a stderr qué archivos se copiarían y sus tamaños sin escribir nada en disco. El JSON de salida se emite igualmente."
|
||||
output: "JSON en stdout: {backup_dir: \"<dir>\", ts: \"<YYYYMMDDTHHmmss>\", profiles: [{profile: \"<name>\", src: \"<path>\", dst: \"<path>\", bytes: N}, ...]}. Perfiles sin Bookmarks se omiten silenciosamente. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Backup de todos los perfiles del chromium-cdp (descubrimiento automático)
|
||||
source $HOME/fn_registry/bash/functions/browser/backup_chrome_bookmarks.sh
|
||||
backup_chrome_bookmarks --user-data-dir "$HOME/.config/chromium-cdp"
|
||||
|
||||
# Previsualizar sin tocar nada
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--dry-run
|
||||
|
||||
# Backup de perfiles específicos
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--profile Default \
|
||||
--profile Personal \
|
||||
--profile "Profile 1"
|
||||
|
||||
# Backup a directorio personalizado
|
||||
backup_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--backup-dir "$HOME/vaults/backups/bookmarks"
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"backup_dir":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups","ts":"20260605T143022","profiles":[{"profile":"Default","src":"/home/enmanuel/.config/chromium-cdp/Default/Bookmarks","dst":"/home/enmanuel/.local/share/web_scraping/bookmarks-backups/20260605T143022/Default/Bookmarks","bytes":4218}]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run backup_chrome_bookmarks_bash_browser -- \
|
||||
--user-data-dir "$HOME/.config/chromium-cdp" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de cualquier sesión de scraping o automatización que modifique bookmarks de Chromium, para tener un snapshot recuperable. También útil como paso previo en pipelines que reorganizan o importan marcadores masivamente. Combínala con `rotate_backups_bash_infra` para aplicar política de retención sobre el directorio de backups.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Copia verbatim para preservar checksum**: el archivo `Bookmarks` de Chromium incluye un campo `checksum` calculado sobre el contenido. Esta función usa `cp -p` sin tocar el contenido — si parseases y reserializases el JSON (con `jq`, `python3 json.dump`, etc.) el checksum quedaría inválido y Chromium podría resetear o ignorar los marcadores al arrancar.
|
||||
- **No requiere Chromium cerrado**: a diferencia de `clean_chrome_profile_extensions`, esta función solo lee el archivo `Bookmarks`. Chromium no mantiene un lock exclusivo sobre él — la copia es segura con el navegador abierto. El archivo refleja el estado en disco en ese instante; cambios en vuelo en memoria no estarán en el backup hasta que Chromium los persista.
|
||||
- **Perfiles sin Bookmarks se omiten silenciosamente**: si un perfil existe pero no tiene el archivo `Bookmarks` (perfil recién creado sin haber abierto el navegador), se salta sin error. Solo aparece en el JSON de salida si fue respaldado.
|
||||
- **System Profile excluido siempre**: el perfil `System Profile` es un perfil interno de Chromium sin datos de usuario y se excluye del descubrimiento automático.
|
||||
- **Sin jq ni python3**: la emisión del JSON de salida se construye con printf de bash puro, sin dependencias externas.
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# backup_chrome_bookmarks — copia byte a byte los archivos Bookmarks de perfiles
|
||||
# Chrome/Chromium a un directorio de backup con timestamp. Preserva el checksum
|
||||
# interno del archivo sin parsear ni reserializar el JSON.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
backup_chrome_bookmarks() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir=""
|
||||
local _profiles=()
|
||||
local _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: backup_chrome_bookmarks --user-data-dir <dir> [--profile <name>]...
|
||||
[--backup-dir <dir>] [--dry-run]
|
||||
|
||||
--user-data-dir (obligatorio) Raíz de perfiles de Chrome/Chromium.
|
||||
Ej: ~/.config/chromium-cdp
|
||||
--profile <name> Nombre de carpeta de perfil a respaldar (repetible).
|
||||
Si no se pasa ninguno → respalda TODOS los perfiles con
|
||||
un archivo Bookmarks (excluye System Profile).
|
||||
--backup-dir <dir> Directorio raíz para backups.
|
||||
Default: ~/.local/share/web_scraping/bookmarks-backups
|
||||
--dry-run Muestra qué copiaría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--backup-dir) _backup_dir="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "backup_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ -z "$_user_data_dir" ]]; then
|
||||
echo "backup_chrome_bookmarks: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "backup_chrome_bookmarks: directorio no encontrado: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── descubrir perfiles si no se pasó ninguno ───────────────────────────────
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
local _candidate
|
||||
while IFS= read -r -d '' _candidate; do
|
||||
local _pname
|
||||
_pname="$(basename "$_candidate")"
|
||||
# Excluir System Profile (perfil interno de Chromium sin datos de usuario)
|
||||
if [[ "$_pname" == "System Profile" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -f "${_candidate}/Bookmarks" ]]; then
|
||||
_profiles+=("$_pname")
|
||||
fi
|
||||
done < <(find "$_user_data_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
|
||||
fi
|
||||
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
echo "backup_chrome_bookmarks: no se encontraron perfiles con archivo Bookmarks en: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── timestamp único para este backup ──────────────────────────────────────
|
||||
local _ts
|
||||
_ts="$(date +%Y%m%dT%H%M%S)"
|
||||
|
||||
# ── procesar perfiles ─────────────────────────────────────────────────────
|
||||
# Construir el array de resultados JSON manualmente (sin jq ni python3)
|
||||
local _results="["
|
||||
local _first=1
|
||||
local _profile
|
||||
|
||||
for _profile in "${_profiles[@]}"; do
|
||||
local _src="${_user_data_dir}/${_profile}/Bookmarks"
|
||||
|
||||
# Si el perfil no tiene Bookmarks, se omite sin error
|
||||
if [[ ! -f "$_src" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local _dst="${_backup_dir}/${_ts}/${_profile}/Bookmarks"
|
||||
local _bytes
|
||||
_bytes="$(wc -c < "$_src")"
|
||||
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "backup_chrome_bookmarks: [dry-run] cp -p \"${_src}\" -> \"${_dst}\" (${_bytes} bytes)" >&2
|
||||
else
|
||||
local _dst_dir
|
||||
_dst_dir="$(dirname "$_dst")"
|
||||
mkdir -p "$_dst_dir"
|
||||
cp -p "$_src" "$_dst"
|
||||
fi
|
||||
|
||||
# Escapar comillas dobles en el path por si acaso
|
||||
local _src_esc="${_src//\"/\\\"}"
|
||||
local _dst_esc="${_dst//\"/\\\"}"
|
||||
local _profile_esc="${_profile//\"/\\\"}"
|
||||
|
||||
local _entry
|
||||
_entry="$(printf '{"profile":"%s","src":"%s","dst":"%s","bytes":%s}' \
|
||||
"$_profile_esc" "$_src_esc" "$_dst_esc" "$_bytes")"
|
||||
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_results+="$_entry"
|
||||
_first=0
|
||||
else
|
||||
_results+=",${_entry}"
|
||||
fi
|
||||
done
|
||||
|
||||
_results+="]"
|
||||
|
||||
# ── emitir resultado JSON ──────────────────────────────────────────────────
|
||||
local _backup_dir_esc="${_backup_dir//\"/\\\"}"
|
||||
printf '{"backup_dir":"%s","ts":"%s","profiles":%s}\n' \
|
||||
"$_backup_dir_esc" "$_ts" "$_results"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
backup_chrome_bookmarks "$@"
|
||||
fi
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: clean_chrome_profile_extensions
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>] [--keep <ext_id>]... [--dry-run]"
|
||||
description: "Purga in-place las extensiones de un perfil Chrome/Chromium existente que no estén en la whitelist --keep: borra sus carpetas de disco y elimina sus referencias de Preferences y Secure Preferences para que Chromium no las reinstale. Complementaria a apply_chromium_extension_policy_bash_browser que evita reinstalación pero no desinstala lo ya instalado en Chromium 148."
|
||||
tags: [navegator, chromium, extensions, profile, cleanup, browser, scraping]
|
||||
uses_functions: [apply_chromium_extension_policy_bash_browser]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/clean_chrome_profile_extensions.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium. Default: ~/.config/chromium"
|
||||
- name: --profile-directory
|
||||
desc: "Nombre del subperfil dentro de user-data-dir. Default: Default"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible, 32 chars minúsculas). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué IDs se conservarían y cuáles se borrarían sin tocar disco ni archivos de preferencias"
|
||||
output: "JSON en stdout: {profile: \"<path>\", kept: [id...], removed: [id...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar Chromium primero (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# Purgar perfil Default dejando solo uBlock Origin Lite
|
||||
source $HOME/fn_registry/bash/functions/browser/clean_chrome_profile_extensions.sh
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh
|
||||
|
||||
# Previsualizar antes de tocar nada
|
||||
clean_chrome_profile_extensions --keep ddkjiahejlhfcafbddmgiahcphecmpfh --dry-run
|
||||
|
||||
# Perfil no-default con whitelist de dos extensiones
|
||||
clean_chrome_profile_extensions \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile-directory "Profile 1" \
|
||||
--keep ddkjiahejlhfcafbddmgiahcphecmpfh \
|
||||
--keep cjpalhdlnbpafiamejdnhcphjbkeiagm
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"profile":"/home/enmanuel/.config/chromium/Default","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["dark-reader-id","another-ext-id"]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run clean_chrome_profile_extensions_bash_browser -- --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala después de reducir la whitelist de extensiones con `apply_chromium_extension_policy_bash_browser` (modo `blocked`), para quitar del disco las que ya estaban instaladas en el perfil: la policy evita que Chromium reinstale extensiones nuevas, pero en Chromium 148 no desinstala las que ya estaban force-instaladas. Esta función hace la purga determinista del estado existente. También útil antes de una sesión de scraping para dejar el perfil con solo las extensiones necesarias.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium reescribe `Preferences` desde memoria al cerrar y desharía toda la purga. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` no se hace este check.
|
||||
- **Combínala con `apply_chromium_extension_policy_bash_browser` (blocked)** para que las extensiones no vuelvan a instalarse la próxima vez que arranques Chromium. Esta función purga el estado actual; la policy evita la reinstalación futura.
|
||||
- **Backup automático de prefs**: antes de editar `Preferences` y `Secure Preferences` la función crea `<archivo>.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. En caso de problemas: `cp Preferences.bak.YYYYMMDD Preferences`.
|
||||
- **Opera por perfil**: actúa sobre `--user-data-dir`/`--profile-directory`/Extensions. Si tienes varios perfiles (`Default`, `Profile 1`, etc.) debes invocarla una vez por cada uno.
|
||||
- **python3 > jq > warn**: para editar el JSON de Preferences usa python3 si está disponible, jq como fallback, y emite un warning a stderr (sin abortar) si ninguno está. En ese caso las carpetas sí se borran pero las referencias en Preferences quedan — Chromium podría intentar reinstalar desde Web Store.
|
||||
- **Secure Preferences HMAC**: la tabla `protection.macs.extensions.settings` también se limpia para evitar que Chromium detecte inconsistencia entre el HMAC y la entrada eliminada y resetee configuraciones. Si la HMAC falla de todas formas, Chromium lo trata como perfil potencialmente corrupto y puede resetear algunas prefs — comportamiento esperado de Chromium, no un bug de esta función.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido o perfil no encontrado |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
| 3 | Directorio Extensions no encontrado |
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# clean_chrome_profile_extensions — purga in-place extensiones fuera de la whitelist
|
||||
# de un perfil Chrome/Chromium existente. Borra las carpetas de disco y limpia las
|
||||
# referencias en Preferences y Secure Preferences para que Chromium no las reinstale.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
clean_chrome_profile_extensions() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir="${HOME}/.config/chromium"
|
||||
local _profile_dir="Default"
|
||||
local _keep=()
|
||||
local _default_ext="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: clean_chrome_profile_extensions [--user-data-dir <dir>] [--profile-directory <name>]
|
||||
[--keep <ext_id>]... [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del perfil. Default: ~/.config/chromium
|
||||
--profile-directory Subperfil. Default: Default
|
||||
--keep <ext_id> ID de extensión a conservar (repetible).
|
||||
Default si no se pasa ninguno: ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)
|
||||
--dry-run Lista qué se borraría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
3 directorio de extensiones no encontrado
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile-directory) _profile_dir="$2"; shift 2 ;;
|
||||
--keep) _keep+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "clean_chrome_profile_extensions: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── whitelist por defecto ──────────────────────────────────────────────────
|
||||
if [[ ${#_keep[@]} -eq 0 ]]; then
|
||||
_keep=("$_default_ext")
|
||||
fi
|
||||
|
||||
# ── construir paths base ───────────────────────────────────────────────────
|
||||
local _profile_path
|
||||
_profile_path="${_user_data_dir}/${_profile_dir}"
|
||||
local _ext_dir="${_profile_path}/Extensions"
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$_profile_path" ]]; then
|
||||
echo "clean_chrome_profile_extensions: perfil no encontrado: ${_profile_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_ext_dir" ]]; then
|
||||
echo "clean_chrome_profile_extensions: directorio de extensiones no encontrado: ${_ext_dir}" >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── guard: chromium NO debe estar corriendo (excepto en dry-run) ──────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
if pgrep -x chromium >/dev/null 2>&1; then
|
||||
echo "clean_chrome_profile_extensions: chromium está corriendo — ciérralo antes de limpiar:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Preferences desde memoria al cerrar y desharía la purga)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── enumerar extensiones instaladas ───────────────────────────────────────
|
||||
local _to_remove=()
|
||||
local _to_keep=()
|
||||
|
||||
while IFS= read -r -d '' _ext_path; do
|
||||
local _ext_id
|
||||
_ext_id="$(basename "$_ext_path")"
|
||||
|
||||
# Siempre ignorar la carpeta Temp (usada durante installs en curso)
|
||||
if [[ "$_ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Comprobar si está en la whitelist
|
||||
local _in_keep=0
|
||||
local _k
|
||||
for _k in "${_keep[@]}"; do
|
||||
if [[ "$_ext_id" == "$_k" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_to_keep+=("$_ext_id")
|
||||
else
|
||||
_to_remove+=("$_ext_id")
|
||||
fi
|
||||
done < <(find "$_ext_dir" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
|
||||
|
||||
# ── modo dry-run: solo informar ────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== clean_chrome_profile_extensions DRY-RUN ===" >&2
|
||||
echo " Perfil : ${_profile_path}" >&2
|
||||
echo " Conservar (${#_to_keep[@]}): ${_to_keep[*]+"${_to_keep[*]}"}" >&2
|
||||
echo " Borrar (${#_to_remove[@]}): ${_to_remove[*]+"${_to_remove[*]}"}" >&2
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── borrar extensiones fuera de la whitelist ───────────────────────────────
|
||||
if [[ ${#_to_remove[@]} -gt 0 ]]; then
|
||||
local _id
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
rm -rf "${_ext_dir}/${_id}"
|
||||
done
|
||||
|
||||
# ── purgar referencias en Preferences y Secure Preferences ────────────
|
||||
# Construir lista Python de IDs eliminados
|
||||
local _py_ids_list=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
local _prefs_file
|
||||
for _prefs_file in "${_profile_path}/Preferences" "${_profile_path}/Secure Preferences"; do
|
||||
if [[ ! -f "$_prefs_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Backup (no sobreescribir backup del mismo día)
|
||||
local _backup="${_prefs_file}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_prefs_file" "$_backup"
|
||||
fi
|
||||
|
||||
# Editar con python3 si está disponible
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "clean_chrome_profile_extensions: advertencia — no se pudo purgar ${_prefs_file} con python3" >&2
|
||||
import sys, json
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
removed_ids = json.loads(sys.argv[2])
|
||||
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 1. extensions.settings.<id>
|
||||
ext_settings = data.get("extensions", {}).get("settings", {})
|
||||
for ext_id in removed_ids:
|
||||
ext_settings.pop(ext_id, None)
|
||||
|
||||
# 2. extensions.pinned_extensions (lista de IDs)
|
||||
pinned = data.get("extensions", {}).get("pinned_extensions", None)
|
||||
if isinstance(pinned, list):
|
||||
data["extensions"]["pinned_extensions"] = [
|
||||
pid for pid in pinned if pid not in removed_ids
|
||||
]
|
||||
|
||||
# 3. protection.macs.extensions.settings.<id> (Secure Preferences HMAC table)
|
||||
try:
|
||||
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
|
||||
for ext_id in removed_ids:
|
||||
mac_ext.pop(ext_id, None)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
|
||||
# Fallback con jq si python3 no está disponible
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
local _tmp_prefs
|
||||
_tmp_prefs="$(mktemp)"
|
||||
local _jq_del=""
|
||||
for _id in "${_to_remove[@]}"; do
|
||||
_jq_del+=" | del(.extensions.settings[\"${_id}\"])"
|
||||
_jq_del+=" | del(.protection.macs.extensions.settings[\"${_id}\"])"
|
||||
done
|
||||
# pinned_extensions como lista
|
||||
_jq_del+=" | if .extensions.pinned_extensions then .extensions.pinned_extensions -= [$(printf '"%s",' "${_to_remove[@]}" | sed 's/,$//')] else . end"
|
||||
jq "${_jq_del:1}" "$_prefs_file" > "$_tmp_prefs" && mv "$_tmp_prefs" "$_prefs_file" || {
|
||||
echo "clean_chrome_profile_extensions: advertencia — jq falló procesando ${_prefs_file}" >&2
|
||||
rm -f "$_tmp_prefs"
|
||||
}
|
||||
else
|
||||
echo "clean_chrome_profile_extensions: advertencia — ni python3 ni jq disponibles; se borraron las carpetas pero no las referencias en $(basename "$_prefs_file")" >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ──────────────────────────────────────────────────
|
||||
_emit_json "$_profile_path" _to_keep _to_remove
|
||||
}
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# _json_array_from_nameref <nameref>
|
||||
# Convierte un array bash (pasado por nombre de variable) en JSON array de strings.
|
||||
_json_array_from_nameref() {
|
||||
local -n _arr_ref="$1"
|
||||
local _out="["
|
||||
local _first=1
|
||||
local _item
|
||||
for _item in "${_arr_ref[@]+"${_arr_ref[@]}"}"; do
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_out+="\"${_item}\""
|
||||
_first=0
|
||||
else
|
||||
_out+=",\"${_item}\""
|
||||
fi
|
||||
done
|
||||
_out+="]"
|
||||
echo "$_out"
|
||||
}
|
||||
|
||||
# _emit_json <profile_path> <kept_nameref> <removed_nameref>
|
||||
_emit_json() {
|
||||
local _p="$1"
|
||||
local _kept_json
|
||||
_kept_json="$(_json_array_from_nameref "$2")"
|
||||
local _removed_json
|
||||
_removed_json="$(_json_array_from_nameref "$3")"
|
||||
printf '{"profile":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_p" "$_kept_json" "$_removed_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
clean_chrome_profile_extensions "$@"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: create_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible> [--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]"
|
||||
description: "Crea un perfil Chrome/Chromium nuevo en un user-data-dir: opcionalmente lanza chromium headless vía systemd-run para que la managed policy instale las extensiones forzadas (uBlock, web_proxy) y luego edita Local State para asignar el nombre legible al perfil. Con --no-launch crea solo la estructura de carpetas y la entrada en Local State sin arrancar Chrome."
|
||||
tags: [navegator, chromium, profile, browser, cdp, headless, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/create_chrome_profile.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Raíz del user-data-dir de Chrome/Chromium. Puede no existir; la función lo crea. Obligatorio."
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, \"Profile 1\", Automation. Obligatorio."
|
||||
- name: --name
|
||||
desc: "Nombre legible visible en el selector de perfil de Chrome, por ejemplo: Work, Aurgi, Bot. Obligatorio."
|
||||
- name: --port
|
||||
desc: "Puerto CDP para el lanzamiento headless. Default: 9250. Usar un valor distinto al 9222 global para no colisionar."
|
||||
- name: --chrome-path
|
||||
desc: "Ruta absoluta al binario chromium/chrome. Si se omite, auto-detecta: chromium, chromium-browser, google-chrome, brave-browser."
|
||||
- name: --no-launch
|
||||
desc: "No lanza chromium. Solo crea la carpeta del perfil y edita Local State con el nombre legible. El perfil no tendrá extensiones instaladas. Útil para tests y CRUD offline."
|
||||
- name: --timeout-sec
|
||||
desc: "Segundos máximos esperando a que Preferences aparezca tras el lanzamiento headless. Default: 25."
|
||||
- name: --dry-run
|
||||
desc: "Describe las acciones que se ejecutarían sin lanzar ni escribir nada. Emite el JSON de resultado con dry_run:true."
|
||||
output: "JSON en stdout: {\"profile\":\"<dir-name>\",\"name\":\"<legible>\",\"launched\":true|false,\"preferences_created\":true|false}. En dry-run añade \"dry_run\":true. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/create_chrome_profile.sh
|
||||
|
||||
# Modo offline (no lanza Chrome, solo CRUD de Local State — seguro para tests)
|
||||
create_chrome_profile \
|
||||
--user-data-dir /tmp/test_udd \
|
||||
--profile "Automation" \
|
||||
--name "Aurgi Bot" \
|
||||
--no-launch
|
||||
# Salida: {"profile":"Automation","name":"Aurgi Bot","launched":false,"preferences_created":false}
|
||||
|
||||
# Modo normal: lanza headless para que la policy instale uBlock y web_proxy,
|
||||
# luego asigna nombre en Local State
|
||||
create_chrome_profile \
|
||||
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
|
||||
--profile "Profile 1" \
|
||||
--name "Work" \
|
||||
--port 9250
|
||||
# Salida: {"profile":"Profile 1","name":"Work","launched":true,"preferences_created":true}
|
||||
|
||||
# Dry-run: describe acciones sin ejecutar nada
|
||||
create_chrome_profile \
|
||||
--user-data-dir "$HOME/.local/share/web_scraping/profiles" \
|
||||
--profile "Default" \
|
||||
--name "Scraping" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para aprovisionar perfiles nuevos en un user-data-dir de automatización antes de lanzar sesiones CDP con `script-navegador` o funciones del grupo `navegator`. En modo normal (sin `--no-launch`) la managed policy instala automáticamente uBlock y la extensión web_proxy en el perfil nuevo; en `--no-launch` sirve para tests unitarios o para crear la entrada de Local State sin depender de Chrome.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Lanzar chromium desde Bash tool de Claude da exit-144**: la función usa `systemd-run --user --collect` para aislar el proceso en su propio cgroup, evitando que el harness del agente lo mate. Esto es obligatorio; lanzar con `&` / `setsid` daría exit-144 en el contexto del agente.
|
||||
- **La managed policy instala las extensiones al arrancar el perfil**: NO pasar `--disable-extensions` — rompería la forcelist. Las extensiones force-listed (`ExtensionInstallForcelist` en `/etc/chromium/policies/managed/extensions.json`) se instalan en el perfil durante el primer arranque; en el headless inicial puede no completar la descarga si no hay red o si el timeout es corto.
|
||||
- **Dos chromium NO pueden compartir el mismo user-data-dir**: si ya hay un chromium corriendo sobre `--user-data-dir`, la función detecta `SingletonLock` y sale con exit 2 antes de lanzar. Para perfiles de automatización paralela, usa un `--user-data-dir` dedicado por perfil.
|
||||
- **Local State debe editarse con Chrome muerto**: la función para el unit de systemd y espera la desaparición de `SingletonLock` antes de editar `Local State`. Si se edita mientras Chrome está vivo, Chrome sobreescribe el archivo desde memoria al salir y los cambios de nombre se pierden.
|
||||
- **`--remote-allow-origins=*` necesita comillas en zsh**: el glob `*` se expande si no va entre comillas. La función pasa el flag correctamente internamente, pero si lo pasas tú en otros scripts acuérdate de las comillas.
|
||||
- **Perfil diario en `~/.config/chromium-cdp`**: en este equipo el fragmento `/etc/chromium.d/cdp` redirige el user-data-dir global a `~/.config/chromium-cdp`. Para automatización usar siempre un `--user-data-dir` dedicado fuera de `~/.config/`.
|
||||
- **Timeout corto puede dar `preferences_created: false`**: el perfil headless tarda entre 2-8 segundos en crear `Preferences` según la carga del sistema. Si se aumenta `--timeout-sec` a 45-60 en máquinas lentas se evitan falsos timeouts.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento obligatorio faltante o binario no encontrado |
|
||||
| 2 | Lock: ya hay un chromium usando el mismo user-data-dir |
|
||||
| 3 | Timeout esperando a que Preferences se cree |
|
||||
| 4 | Error editando Local State (JSON inválido tras escritura) |
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env bash
|
||||
# create_chrome_profile — crea un perfil Chrome/Chromium nuevo en un user-data-dir,
|
||||
# opcionalmente lanzando chromium headless para que la managed policy instale las
|
||||
# extensiones forzadas (uBlock, web_proxy). Edita Local State para asignar el nombre
|
||||
# legible al perfil.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
create_chrome_profile() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _udd=""
|
||||
local _profile_dir=""
|
||||
local _name=""
|
||||
local _port=9250
|
||||
local _chrome_path=""
|
||||
local _no_launch=0
|
||||
local _timeout_sec=25
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: create_chrome_profile --user-data-dir <dir> --profile <dir-name> --name <legible>
|
||||
[--port N] [--chrome-path <path>] [--no-launch] [--timeout-sec N] [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile Nombre de la carpeta del perfil dentro de user-data-dir, ej: Default,
|
||||
"Profile 1", Automation (obligatorio).
|
||||
--name Nombre legible que aparece en el selector de perfil, ej: Work, Aurgi
|
||||
(obligatorio).
|
||||
--port Puerto CDP para el lanzamiento headless. Default: 9250.
|
||||
Usar un puerto distinto al 9222 global para no chocar.
|
||||
--chrome-path Ruta explícita al binario chromium/chrome. Auto-detecta si se omite.
|
||||
--no-launch No lanza chromium. Crea la carpeta y edita Local State offline.
|
||||
El perfil no tendrá extensiones instaladas; útil para tests/CRUD.
|
||||
--timeout-sec Segundos esperando a que Preferences aparezca tras el lanzamiento.
|
||||
Default: 25.
|
||||
--dry-run Describe las acciones sin lanzar ni escribir nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 lock: ya hay un chromium usando este user-data-dir
|
||||
3 timeout esperando a que Preferences se cree
|
||||
4 error editando Local State (JSON inválido tras escritura)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _udd="$2"; shift 2 ;;
|
||||
--profile) _profile_dir="$2"; shift 2 ;;
|
||||
--name) _name="$2"; shift 2 ;;
|
||||
--port) _port="$2"; shift 2 ;;
|
||||
--chrome-path) _chrome_path="$2"; shift 2 ;;
|
||||
--no-launch) _no_launch=1; shift ;;
|
||||
--timeout-sec) _timeout_sec="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "create_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones obligatorias ──────────────────────────────────────────────
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "create_chrome_profile: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_profile_dir" ]]; then
|
||||
echo "create_chrome_profile: --profile es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_name" ]]; then
|
||||
echo "create_chrome_profile: --name es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _profile_path="${_udd}/${_profile_dir}"
|
||||
local _local_state="${_udd}/Local State"
|
||||
local _prefs_file="${_profile_path}/Preferences"
|
||||
|
||||
# ── guard: lock por user-data-dir ─────────────────────────────────────────
|
||||
# Dos procesos chromium no pueden compartir el mismo user-data-dir.
|
||||
if [[ $_dry_run -eq 0 && $_no_launch -eq 0 ]]; then
|
||||
local _singleton="${_udd}/SingletonLock"
|
||||
if [[ -e "$_singleton" ]]; then
|
||||
echo "create_chrome_profile: ya hay un chromium corriendo con --user-data-dir=${_udd}" >&2
|
||||
echo " (encontrado: ${_singleton})" >&2
|
||||
echo " Ciérralo o usa un user-data-dir distinto." >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── detección del binario chromium ────────────────────────────────────────
|
||||
local _bin=""
|
||||
if [[ -n "$_chrome_path" ]]; then
|
||||
if [[ ! -x "$_chrome_path" ]]; then
|
||||
echo "create_chrome_profile: binario no encontrado o no ejecutable: ${_chrome_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
_bin="$_chrome_path"
|
||||
elif [[ $_no_launch -eq 0 ]]; then
|
||||
for _candidate in chromium chromium-browser google-chrome brave-browser; do
|
||||
if command -v "$_candidate" &>/dev/null; then
|
||||
_bin="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$_bin" ]]; then
|
||||
echo "create_chrome_profile: no se encontró binario chromium en PATH" >&2
|
||||
echo " Probados: chromium, chromium-browser, google-chrome, brave-browser" >&2
|
||||
echo " Usa --chrome-path o --no-launch." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== create_chrome_profile DRY-RUN ===" >&2
|
||||
echo " user-data-dir : ${_udd}" >&2
|
||||
echo " profile : ${_profile_dir}" >&2
|
||||
echo " name : ${_name}" >&2
|
||||
if [[ $_no_launch -eq 1 ]]; then
|
||||
echo " modo : --no-launch (sin chromium)" >&2
|
||||
echo " acciones : mkdir -p ${_profile_path}" >&2
|
||||
echo " editar ${_local_state} → info_cache + profiles_order" >&2
|
||||
else
|
||||
echo " binario : ${_bin}" >&2
|
||||
echo " puerto CDP : ${_port}" >&2
|
||||
echo " timeout : ${_timeout_sec}s" >&2
|
||||
echo " acciones : systemd-run unit=create-prof-<rand> chromium headless" >&2
|
||||
echo " poll Preferences hasta ${_timeout_sec}s" >&2
|
||||
echo " systemctl --user stop unit" >&2
|
||||
echo " editar ${_local_state} → info_cache + profiles_order" >&2
|
||||
fi
|
||||
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":false,"dry_run":true}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── crear directorio del perfil ───────────────────────────────────────────
|
||||
mkdir -p "$_profile_path"
|
||||
|
||||
# ── también asegurar que user-data-dir existe ──────────────────────────────
|
||||
mkdir -p "$_udd"
|
||||
|
||||
# ── modo --no-launch: solo estructura + Local State ────────────────────────
|
||||
local _launched=false
|
||||
local _prefs_created=false
|
||||
|
||||
if [[ $_no_launch -eq 1 ]]; then
|
||||
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
_prefs_created=true
|
||||
fi
|
||||
printf '{"profile":"%s","name":"%s","launched":false,"preferences_created":%s}\n' \
|
||||
"$_profile_dir" "$_name" "$_prefs_created"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── lanzar chromium headless vía systemd-run ──────────────────────────────
|
||||
# systemd-run --user aísla el proceso del cgroup del agente (evita exit-144).
|
||||
# NO se pasa --disable-extensions para que la managed policy instale las
|
||||
# extensiones force-listed (uBlock, web_proxy).
|
||||
local _rand
|
||||
_rand="$(tr -dc 'a-z0-9' </dev/urandom | head -c 8 2>/dev/null || echo "$$")"
|
||||
local _unit="create-prof-${_rand}"
|
||||
|
||||
systemd-run \
|
||||
--user \
|
||||
--collect \
|
||||
--unit="$_unit" \
|
||||
--setenv=DISPLAY=:0 \
|
||||
--setenv=XAUTHORITY="${HOME}/.Xauthority" \
|
||||
"$_bin" \
|
||||
"--user-data-dir=${_udd}" \
|
||||
"--profile-directory=${_profile_dir}" \
|
||||
"--headless=new" \
|
||||
"--no-first-run" \
|
||||
"--remote-debugging-port=${_port}" \
|
||||
"--remote-allow-origins=*" \
|
||||
"about:blank" 2>/dev/null || true
|
||||
|
||||
_launched=true
|
||||
|
||||
# ── poll: esperar a que Preferences exista ────────────────────────────────
|
||||
local _elapsed=0
|
||||
while [[ $_elapsed -lt $_timeout_sec ]]; do
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
_prefs_created=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
(( _elapsed++ )) || true
|
||||
done
|
||||
|
||||
# ── detener el unit Y matar TODO el árbol de chromium de este udd ───────────
|
||||
# Necesario para poder editar Local State sin que Chrome lo sobreescriba. Ni el
|
||||
# `systemctl stop` ni un `pkill -f --user-data-dir=` bastan: los procesos hijos
|
||||
# (zygote/gpu/renderer) no repiten el flag --user-data-dir pero sí referencian la
|
||||
# ruta del user-data-dir en otros argumentos. Los matamos por PID seleccionando
|
||||
# los procesos chromium cuyo cmdline contiene la ruta del udd (seguro: no mata
|
||||
# este propio script porque filtramos por '[c]hromium').
|
||||
systemctl --user kill -s SIGKILL "$_unit" 2>/dev/null || true
|
||||
systemctl --user stop "$_unit" 2>/dev/null || true
|
||||
# Matar por PID los procesos cuyo comm es exactamente "chromium" (pgrep -x) y cuyo cmdline
|
||||
# contiene la ruta del udd. Usamos pgrep -x para NO auto-matchear grep/pgrep: el path del udd
|
||||
# contiene la cadena "chromium" (~/.config/chromium-cdp).
|
||||
local _wait=0 _p _pids
|
||||
while :; do
|
||||
_pids=""
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _pids="$_pids $_p"
|
||||
done
|
||||
[[ -z "${_pids// }" ]] && break
|
||||
# shellcheck disable=SC2086
|
||||
kill -TERM $_pids 2>/dev/null || true
|
||||
sleep 0.5
|
||||
(( _wait++ )) || true
|
||||
if [[ $_wait -ge 20 ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $_pids 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
done
|
||||
rm -f "${_udd}/SingletonLock" 2>/dev/null || true
|
||||
|
||||
if [[ "$_prefs_created" == false ]]; then
|
||||
echo "create_chrome_profile: timeout (${_timeout_sec}s) esperando a que se cree: ${_prefs_file}" >&2
|
||||
echo " El directorio del perfil puede existir pero está vacío." >&2
|
||||
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":false,"error":"timeout"}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── editar Local State para asignar nombre legible ────────────────────────
|
||||
_update_local_state "$_udd" "$_local_state" "$_profile_dir" "$_name"
|
||||
|
||||
printf '{"profile":"%s","name":"%s","launched":true,"preferences_created":true}\n' \
|
||||
"$_profile_dir" "$_name"
|
||||
}
|
||||
|
||||
# ── helper: editar Local State con python3 ────────────────────────────────────
|
||||
# Crea/actualiza info_cache.<profile_dir> con name + is_using_default_name=false
|
||||
# y añade profile_dir a profiles_order si no está.
|
||||
_update_local_state() {
|
||||
local _udd="$1"
|
||||
local _local_state="$2"
|
||||
local _profile_dir="$3"
|
||||
local _name="$4"
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
# Si Local State no existe, crear una estructura mínima
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
printf '{"profile":{"info_cache":{},"profiles_order":[]}}\n' > "$_local_state"
|
||||
fi
|
||||
|
||||
# Backup antes de modificar (no sobreescribir el del mismo día)
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# Editar con python3
|
||||
if ! python3 - "$_local_state" "$_profile_dir" "$_name" <<'PY'; then
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
prof_name = sys.argv[3]
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Asegurar estructura profile
|
||||
profile_section = data.setdefault("profile", {})
|
||||
info_cache = profile_section.setdefault("info_cache", {})
|
||||
|
||||
# Crear o actualizar la entrada del perfil en info_cache
|
||||
entry = info_cache.setdefault(prof_dir, {})
|
||||
entry["name"] = prof_name
|
||||
entry["is_using_default_name"] = False
|
||||
|
||||
# Añadir a profiles_order si no está
|
||||
order = profile_section.setdefault("profiles_order", [])
|
||||
if prof_dir not in order:
|
||||
order.append(prof_dir)
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "create_chrome_profile: error editando Local State con python3" >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Validar JSON tras escritura
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "create_chrome_profile: JSON inválido tras escribir Local State; restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 4
|
||||
fi
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
create_chrome_profile "$@"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: delete_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]"
|
||||
description: "Borra por completo uno o varios perfiles Chrome/Chromium: elimina la carpeta del perfil del disco y limpia todas sus referencias en Local State (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups). Requiere que Chromium esté cerrado. Hace backup automático de Local State antes de editar y valida el JSON resultante restaurando el backup si es inválido."
|
||||
tags: [navegator, chromium, profile, cleanup, browser, scraping]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/delete_chrome_profile.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio). Ej: ~/.config/chromium"
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil a borrar (repetible, mínimo uno obligatorio). Ej: 'Default', 'Profile 1'"
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué carpetas borraría y qué claves de Local State quitaría sin tocar nada. No activa el guard de chromium cerrado."
|
||||
output: "JSON en stdout. Modo real: {deleted:[{profile, dir_removed, local_state_cleaned}...], last_used:'<nuevo>', backup:'Local State.bak.YYYYMMDD'}. Modo dry-run: {dry_run:true, would_delete:[{profile, dir_exists, would_remove, local_state_would_clean}...]}. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar Chromium primero (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# Borrar un perfil
|
||||
source $HOME/fn_registry/bash/functions/browser/delete_chrome_profile.sh
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1"
|
||||
# Salida: {"deleted":[{"profile":"Profile 1","dir_removed":true,"local_state_cleaned":true}],"last_used":"Default","backup":"Local State.bak.20260606"}
|
||||
|
||||
# Borrar varios perfiles a la vez
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1" \
|
||||
--profile "Profile 2"
|
||||
|
||||
# Previsualizar sin tocar nada (no requiere Chromium cerrado)
|
||||
delete_chrome_profile \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--profile "Profile 1" \
|
||||
--dry-run
|
||||
# Salida: {"dry_run":true,"would_delete":[{"profile":"Profile 1","dir_exists":true,"would_remove":true,"local_state_would_clean":true}]}
|
||||
|
||||
# Con un user-data-dir sintético para pruebas
|
||||
mkdir -p /tmp/test_udd/Default /tmp/test_udd/"Profile 1"
|
||||
echo '{"profile":{"info_cache":{"Default":{},"Profile 1":{}},"profiles_order":["Default","Profile 1"],"last_active_profiles":["Profile 1"],"last_used":"Profile 1"},"variations_google_groups":{}}' \
|
||||
> "/tmp/test_udd/Local State"
|
||||
delete_chrome_profile --user-data-dir /tmp/test_udd --profile "Profile 1" --dry-run
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run delete_chrome_profile_bash_browser -- \
|
||||
--user-data-dir "$HOME/.config/chromium" --profile "Profile 1" --dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala cuando necesites limpiar completamente un perfil de Chromium: antes de crear un perfil de scraping fresco, para depurar problemas de perfiles corruptos, o para liberar espacio eliminando perfiles de sesión temporales. A diferencia de borrar solo la carpeta, esta función también retira las referencias de `Local State` para que Chromium no muestre el perfil fantasma ni intente acceder a él al arrancar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado antes de ejecutar en modo real**. Chromium reescribe `Local State` desde memoria al cerrar y desharía todos los cambios. La función comprueba `pgrep -x chromium` y aborta con exit 2 si detecta procesos vivos. En `--dry-run` este check no se activa.
|
||||
- **Operación destructiva e irreversible**: todos los datos del perfil (cookies, logins guardados, historial, caché, contraseñas) se pierden permanentemente al borrar la carpeta. No hay papelera.
|
||||
- **Backup automático de Local State**: antes de editar, la función crea `<udd>/Local State.bak.YYYYMMDD`. Si ya existe un backup del día no lo sobreescribe. Restaurar manualmente: `cp "Local State.bak.YYYYMMDD" "Local State"`.
|
||||
- **Validación JSON tras edición**: si el JSON de Local State queda inválido (raro pero posible con perfiles con nombres muy especiales), la función restaura el backup automáticamente y sale con exit != 0.
|
||||
- **Nombres de perfil con espacios**: los nombres como `"Profile 1"` se pasan entre comillas al script Python. El parsing usa `json.loads` por lo que los espacios no dan problemas, pero deben pasarse correctamente en el shell: `--profile "Profile 1"`.
|
||||
- **python3 > jq > warning**: usa python3 para editar Local State, jq como fallback. Si ninguno está disponible, las carpetas se borran pero Local State queda sin modificar (Chromium podría mostrar perfiles fantasma al arrancar).
|
||||
- **last_used reasignado automáticamente**: si el perfil borrado era el `last_used`, la función asigna el primer perfil restante en `info_cache`. Si no queda ningún perfil, `last_used` queda como cadena vacía.
|
||||
- **No afecta a `--profile Default` si es el único perfil**: lo borrará igualmente — Chromium puede quedar sin ningún perfil configurado y recreará Default al arrancar.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|-------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido, directorio o Local State no encontrado, JSON inválido tras edición |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bash
|
||||
# delete_chrome_profile — borra por completo uno o varios perfiles Chrome/Chromium:
|
||||
# elimina la carpeta del perfil y limpia todas las referencias en Local State
|
||||
# (info_cache, profiles_order, last_active_profiles, last_used, variations_google_groups).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
delete_chrome_profile() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir=""
|
||||
local _profiles=()
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: delete_chrome_profile --user-data-dir <dir> --profile <name> [--profile <name>]... [--dry-run]
|
||||
|
||||
--user-data-dir <dir> Ruta raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile <name> Nombre de la carpeta del perfil, ej. "Default" o "Profile 1"
|
||||
(repetible, al menos uno obligatorio).
|
||||
--dry-run Muestra qué borraría y qué claves de Local State quitaría
|
||||
sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "delete_chrome_profile: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones de argumentos ────────────────────────────────────────────
|
||||
if [[ -z "$_user_data_dir" ]]; then
|
||||
echo "delete_chrome_profile: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
echo "delete_chrome_profile: se requiere al menos un --profile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "delete_chrome_profile: user-data-dir no encontrado: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _local_state="${_user_data_dir}/Local State"
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
echo "delete_chrome_profile: Local State no encontrado: ${_local_state}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
|
||||
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
|
||||
# para NO auto-matchear el propio `grep`/`pgrep` del pipe: como el path del udd contiene la
|
||||
# cadena "chromium" (p.ej. ~/.config/chromium-cdp), un `pgrep -af '[c]hromium' | grep <udd>`
|
||||
# se detecta a sí mismo. pgrep -x chromium solo lista procesos cuyo nombre es exactamente
|
||||
# "chromium" (el navegador), nunca grep/pgrep/bash.
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _p _busy=0
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "delete_chrome_profile: hay un chromium con este user-data-dir abierto — ciérralo antes de borrar perfiles:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Local State desde memoria al cerrar y desharía el borrado)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== delete_chrome_profile DRY-RUN ===" >&2
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
if [[ -d "$_pdir" ]]; then
|
||||
echo " [borraría] rm -rf ${_pdir}" >&2
|
||||
else
|
||||
echo " [no existe] ${_pdir}" >&2
|
||||
fi
|
||||
echo " [Local State] quitaría claves para perfil: '${_p}'" >&2
|
||||
echo " profile.info_cache.${_p}" >&2
|
||||
echo " profile.profiles_order (entrada '${_p}')" >&2
|
||||
echo " profile.last_active_profiles (entrada '${_p}')" >&2
|
||||
echo " profile.last_used (si == '${_p}', reasignar)" >&2
|
||||
echo " variations_google_groups.${_p} (si existe)" >&2
|
||||
done
|
||||
|
||||
# Construir JSON de dry-run inline
|
||||
local _dry_items="" _dry_first=1
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
local _sep="" _exists="false"
|
||||
[[ $_dry_first -eq 0 ]] && _sep=","
|
||||
_dry_first=0
|
||||
[[ -d "$_pdir" ]] && _exists="true"
|
||||
_dry_items+="${_sep}{\"profile\":\"${_p}\",\"dir_exists\":${_exists},\"would_remove\":${_exists},\"local_state_would_clean\":true}"
|
||||
done
|
||||
printf '{"dry_run":true,"would_delete":[%s]}\n' "$_dry_items"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── backup de Local State (no sobreescribir el del día) ───────────────────
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# ── borrar carpetas de perfil ──────────────────────────────────────────────
|
||||
local _deleted_results=() # "profile|dir_removed|ls_cleaned"
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
local _pdir="${_user_data_dir}/${_p}"
|
||||
local _dir_removed=false
|
||||
if [[ -d "$_pdir" ]]; then
|
||||
rm -rf "$_pdir"
|
||||
_dir_removed=true
|
||||
fi
|
||||
_deleted_results+=("${_p}|${_dir_removed}|false")
|
||||
done
|
||||
|
||||
# ── construir lista Python de perfiles a eliminar ─────────────────────────
|
||||
local _py_profiles_list=""
|
||||
for _p in "${_profiles[@]}"; do
|
||||
_py_profiles_list+="\"${_p}\","
|
||||
done
|
||||
_py_profiles_list="[${_py_profiles_list%,}]"
|
||||
|
||||
# ── editar Local State con python3 ────────────────────────────────────────
|
||||
local _ls_cleaned=false
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$_local_state" "$_py_profiles_list" <<'PY'
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
profiles_to_delete = json.loads(sys.argv[2])
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
profile_section = data.get("profile", {})
|
||||
|
||||
# 1. profile.info_cache — eliminar cada perfil
|
||||
info_cache = profile_section.get("info_cache", {})
|
||||
for p in profiles_to_delete:
|
||||
info_cache.pop(p, None)
|
||||
|
||||
# 2. profile.profiles_order — quitar entradas del perfil
|
||||
if "profiles_order" in profile_section and isinstance(profile_section["profiles_order"], list):
|
||||
profile_section["profiles_order"] = [
|
||||
x for x in profile_section["profiles_order"] if x not in profiles_to_delete
|
||||
]
|
||||
|
||||
# 3. profile.last_active_profiles — quitar entradas del perfil
|
||||
if "last_active_profiles" in profile_section and isinstance(profile_section["last_active_profiles"], list):
|
||||
profile_section["last_active_profiles"] = [
|
||||
x for x in profile_section["last_active_profiles"] if x not in profiles_to_delete
|
||||
]
|
||||
|
||||
# 4. profile.last_used — reasignar si apunta a un perfil borrado
|
||||
last_used = profile_section.get("last_used", "")
|
||||
if last_used in profiles_to_delete:
|
||||
remaining = [k for k in info_cache.keys() if k not in profiles_to_delete]
|
||||
profile_section["last_used"] = remaining[0] if remaining else ""
|
||||
|
||||
# 5. variations_google_groups — limpiar entradas del perfil (si existe)
|
||||
vgg = data.get("variations_google_groups", {})
|
||||
for p in profiles_to_delete:
|
||||
vgg.pop(p, None)
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
_ls_cleaned=true
|
||||
|
||||
# ── fallback con jq ───────────────────────────────────────────────────────
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
local _tmp_ls
|
||||
_tmp_ls="$(mktemp)"
|
||||
local _jq_expr="."
|
||||
for _p in "${_profiles[@]}"; do
|
||||
_jq_expr+=" | del(.profile.info_cache[\"${_p}\"])"
|
||||
_jq_expr+=" | del(.variations_google_groups[\"${_p}\"])"
|
||||
_jq_expr+=" | if .profile.profiles_order then .profile.profiles_order -= [\"${_p}\"] else . end"
|
||||
_jq_expr+=" | if .profile.last_active_profiles then .profile.last_active_profiles -= [\"${_p}\"] else . end"
|
||||
done
|
||||
if jq "${_jq_expr}" "$_local_state" > "$_tmp_ls" 2>/dev/null; then
|
||||
mv "$_tmp_ls" "$_local_state"
|
||||
_ls_cleaned=true
|
||||
else
|
||||
echo "delete_chrome_profile: advertencia — jq falló editando Local State" >&2
|
||||
rm -f "$_tmp_ls"
|
||||
fi
|
||||
|
||||
else
|
||||
echo "delete_chrome_profile: advertencia — ni python3 ni jq disponibles; carpetas borradas pero Local State no modificado" >&2
|
||||
fi
|
||||
|
||||
# ── validar que el JSON resultante sigue siendo parseable ─────────────────
|
||||
if [[ "$_ls_cleaned" == "true" ]]; then
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if ! python3 -c "import sys, json; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "delete_chrome_profile: JSON de Local State inválido tras edición — restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── actualizar _deleted_results con ls_cleaned ────────────────────────────
|
||||
local _updated_results=()
|
||||
for _entry in "${_deleted_results[@]}"; do
|
||||
local _ep _edr _els
|
||||
IFS='|' read -r _ep _edr _els <<< "$_entry"
|
||||
_updated_results+=("${_ep}|${_edr}|${_ls_cleaned}")
|
||||
done
|
||||
|
||||
# ── leer last_used resultante ──────────────────────────────────────────────
|
||||
local _new_last_used=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_new_last_used="$(python3 -c "
|
||||
import sys, json
|
||||
data = json.load(open(sys.argv[1]))
|
||||
print(data.get('profile', {}).get('last_used', ''))
|
||||
" "$_local_state" 2>/dev/null || echo "")"
|
||||
fi
|
||||
|
||||
# ── construir JSON de resultado inline ────────────────────────────────────
|
||||
local _result_items="" _res_first=1
|
||||
for _entry in "${_updated_results[@]+"${_updated_results[@]}"}"; do
|
||||
local _pn _dr _lc
|
||||
IFS='|' read -r _pn _dr _lc <<< "$_entry"
|
||||
local _rsep=""
|
||||
[[ $_res_first -eq 0 ]] && _rsep=","
|
||||
_res_first=0
|
||||
_result_items+="${_rsep}{\"profile\":\"${_pn}\",\"dir_removed\":${_dr},\"local_state_cleaned\":${_lc}}"
|
||||
done
|
||||
printf '{"deleted":[%s],"last_used":"%s","backup":"Local State.bak.%s"}\n' \
|
||||
"$_result_items" "$_new_last_used" "$_today"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then
|
||||
delete_chrome_profile "$@"
|
||||
fi
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: prepare_chrome_profile
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> [--keep <ext_id>]... [--force]"
|
||||
description: "Clona un user-data-dir de Chrome/Chromium creando un perfil de scraping limpio: conserva solo las extensiones de una lista blanca (por defecto uBlock Origin Lite) y excluye caché, locks y sesiones antiguas."
|
||||
tags: [chrome, browser, profile, scraping, extensions, navegator]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/prepare_chrome_profile.sh"
|
||||
params:
|
||||
- name: --src
|
||||
desc: "user-data-dir origen con un perfil Chrome/Chromium ya configurado (debe existir --src/Default)"
|
||||
- name: --dst
|
||||
desc: "Ruta de destino del nuevo perfil; no debe existir salvo que se pase --force"
|
||||
- name: --keep
|
||||
desc: "ID de extensión Chrome a conservar (repetible). Si no se pasa ninguno el default es ddkjiahejlhfcafbddmgiahcphecmpfh (uBlock Origin Lite)"
|
||||
- name: --force
|
||||
desc: "Borra --dst si existe antes de recrearlo. Sin este flag la función aborta si --dst ya existe"
|
||||
output: "JSON en stdout: {dst, kept: [id...], removed: [id...]}. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/prepare_chrome_profile.sh
|
||||
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile"
|
||||
|
||||
# Con extensión adicional conservada
|
||||
prepare_chrome_profile \
|
||||
--src "$HOME/.config/chromium" \
|
||||
--dst "$HOME/.local/share/web_scraping/chrome-profile" \
|
||||
--keep "ddkjiahejlhfcafbddmgiahcphecmpfh" \
|
||||
--keep "cjpalhdlnbpafiamejdnhcphjbkeiagm" \
|
||||
--force
|
||||
|
||||
# Salida esperada (ejemplo):
|
||||
# {"dst":"/home/enmanuel/.local/share/web_scraping/chrome-profile","kept":["ddkjiahejlhfcafbddmgiahcphecmpfh"],"removed":["abcdefghijklmnopabcdefghijklmnop","dark-reader-id"]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala antes de lanzar una sesión de scraping/automatización para partir de un perfil aislado: con uBlock Origin Lite activo (menos anuncios/trackers = DOM más limpio, respuestas más rápidas) pero sin extensiones que interfieren (Dark Reader muta colores del DOM, NoScript bloquea JS, OneTab modifica tabs). También sirve para aislar sesiones de diferentes proyectos de scraping sin contaminar el perfil personal.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chrome debe estar CERRADO sobre `--src`** antes de ejecutar. Los archivos SQLite (`Cookies`, `History`, `Login Data`, etc.) estarán bloqueados si Chrome está abierto, y `rsync` copiará versiones inconsistentes. Verificar con `pgrep -x chromium` o `pgrep -x chrome`.
|
||||
- **HMAC de Secure Preferences**: el archivo `Local State` contiene la semilla HMAC que Chrome usa para verificar `Preferences` y `Secure Preferences`. Si no se copia (o se copia entre máquinas distintas con distinto binding), Chrome puede invalidar las extensiones al arrancar y resetear configuraciones. La función copia `Local State` automáticamente, pero la copia entre máquinas puede seguir produciendo resets de extensiones — esto es comportamiento esperado de Chrome, no un bug de esta función.
|
||||
- **Purga de referencias en Preferences**: tras borrar las carpetas de extensiones fuera de la whitelist, la función también elimina con `python3` las entradas `extensions.settings.<id>` de `Default/Preferences` y `Default/Secure Preferences`, los IDs de `extensions.pinned_extensions` y las claves `protection.macs.extensions.settings.<id>`. Sin esta limpieza Chrome detecta las entradas en Preferences (con `from_webstore`/install_source) y **vuelve a descargar la extensión del Web Store al arrancar**, deshaciendo el filtrado (caso real: Dark Reader reaparece y oscurece páginas rompiendo screenshots). Si `python3` falla al procesar un Preferences concreto se emite un warning a stderr pero la función no aborta — el borrado de carpetas ya es el efecto principal.
|
||||
- **`--force` borra `--dst` completamente**: si `--dst` es un perfil con datos que quieres conservar, no uses `--force` sin antes hacer backup.
|
||||
- **Extensiones instaladas desde Web Store vs unpacked**: esta función opera sobre la carpeta `Extensions/` física. Las extensiones instaladas desde la Web Store tienen IDs de 32 caracteres en minúsculas. Las extensiones unpacked (`--load-extension`) no viven en `Extensions/` y no se ven afectadas.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento inválido o `--src/Default` no existe |
|
||||
| 2 | `--dst` ya existe y no se pasó `--force` |
|
||||
| 3 | `--src` y `--dst` resuelven al mismo path real |
|
||||
| 4 | Error durante `rsync` |
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bash
|
||||
# prepare_chrome_profile — clona un user-data-dir de Chrome/Chromium conservando solo
|
||||
# las extensiones de una lista blanca. Sirve para perfiles de scraping limpios.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── defaults ──────────────────────────────────────────────────────────────────
|
||||
_SRC=""
|
||||
_DST=""
|
||||
_FORCE=0
|
||||
# uBlock Origin Lite por defecto
|
||||
_KEEP=()
|
||||
_DEFAULT_EXT="ddkjiahejlhfcafbddmgiahcphecmpfh"
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: prepare_chrome_profile --src <user-data-dir> --dst <user-data-dir> \
|
||||
[--keep <ext_id>]... [--force]
|
||||
|
||||
--src user-data-dir origen (ej. $HOME/.config/chromium)
|
||||
--dst user-data-dir destino a crear
|
||||
--keep ID de extensión a conservar (repetible). Default: uBlock Origin Lite
|
||||
--force si --dst existe, lo borra y recrea; sin flag aborta si existe
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 --dst ya existe y no se pasó --force
|
||||
3 --src igual a --dst (mismo path real)
|
||||
4 error de copia/rsync
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--src) _SRC="$2"; shift 2 ;;
|
||||
--dst) _DST="$2"; shift 2 ;;
|
||||
--keep) _KEEP+=("$2"); shift 2 ;;
|
||||
--force) _FORCE=1; shift ;;
|
||||
-h|--help) _usage ;;
|
||||
*) echo "prepare_chrome_profile: argumento desconocido: $1" >&2; _usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones básicas ──────────────────────────────────────────────────────
|
||||
if [[ -z "$_SRC" || -z "$_DST" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst son obligatorios" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_SRC/Default" ]]; then
|
||||
echo "prepare_chrome_profile: $_SRC/Default no existe; ¿es un user-data-dir válido?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolver paths reales para comparar (evitar borrar src cuando src==dst)
|
||||
_SRC_REAL="$(realpath "$_SRC")"
|
||||
_DST_REAL="$(realpath -m "$_DST")" # -m: no requiere que exista
|
||||
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL" ]]; then
|
||||
echo "prepare_chrome_profile: --src y --dst resuelven al mismo path: $_SRC_REAL" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# También rechazar si --dst es prefijo de --src (evitar borrar el origen)
|
||||
if [[ "$_SRC_REAL" == "$_DST_REAL"/* ]]; then
|
||||
echo "prepare_chrome_profile: --src está dentro de --dst; operación peligrosa, abortando" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ── lista blanca de extensiones ───────────────────────────────────────────────
|
||||
if [[ ${#_KEEP[@]} -eq 0 ]]; then
|
||||
_KEEP=("$_DEFAULT_EXT")
|
||||
fi
|
||||
|
||||
# ── gestionar destino ─────────────────────────────────────────────────────────
|
||||
if [[ -d "$_DST" ]]; then
|
||||
if [[ $_FORCE -eq 1 ]]; then
|
||||
rm -rf "$_DST"
|
||||
else
|
||||
echo "prepare_chrome_profile: $_DST ya existe; usa --force para sobreescribir" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$_DST/Default"
|
||||
|
||||
# ── copiar Local State (HMAC seed para Secure Preferences) ────────────────────
|
||||
if [[ -f "$_SRC/Local State" ]]; then
|
||||
cp "$_SRC/Local State" "$_DST/Local State"
|
||||
fi
|
||||
|
||||
# ── rsync del perfil Default excluyendo caché y locks ─────────────────────────
|
||||
rsync -a \
|
||||
--exclude='Cache/' \
|
||||
--exclude='Code Cache/' \
|
||||
--exclude='GPUCache/' \
|
||||
--exclude='Dawn Cache/' \
|
||||
--exclude='DawnGraphiteCache/' \
|
||||
--exclude='DawnWebGPUCache/' \
|
||||
--exclude='Service Worker/CacheStorage/' \
|
||||
--exclude='Service Worker/ScriptCache/' \
|
||||
--exclude='Singleton*' \
|
||||
--exclude='*.lock' \
|
||||
--exclude='lockfile' \
|
||||
--exclude='Sessions/' \
|
||||
--exclude='Session Storage/' \
|
||||
--exclude='Current Session' \
|
||||
--exclude='Current Tabs' \
|
||||
--exclude='Last Session' \
|
||||
--exclude='Last Tabs' \
|
||||
"$_SRC/Default/" "$_DST/Default/" || {
|
||||
echo "prepare_chrome_profile: rsync falló (exit $?)" >&2
|
||||
exit 4
|
||||
}
|
||||
|
||||
# ── eliminar extensiones fuera de la lista blanca ────────────────────────────
|
||||
_EXT_DIR="$_DST/Default/Extensions"
|
||||
_removed=()
|
||||
_kept=()
|
||||
|
||||
if [[ -d "$_EXT_DIR" ]]; then
|
||||
while IFS= read -r -d '' ext_path; do
|
||||
ext_id="$(basename "$ext_path")"
|
||||
# Conservar siempre la carpeta Temp (usada por Chrome durante installs)
|
||||
if [[ "$ext_id" == "Temp" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Comprobar si está en la lista blanca
|
||||
_in_keep=0
|
||||
for keep_id in "${_KEEP[@]}"; do
|
||||
if [[ "$ext_id" == "$keep_id" ]]; then
|
||||
_in_keep=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ $_in_keep -eq 1 ]]; then
|
||||
_kept+=("$ext_id")
|
||||
else
|
||||
rm -rf "$ext_path"
|
||||
_removed+=("$ext_id")
|
||||
fi
|
||||
done < <(find "$_EXT_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
|
||||
fi
|
||||
|
||||
# ── purgar referencias a extensiones eliminadas en Preferences ───────────────
|
||||
# Chrome re-descarga del Web Store cualquier extensión que aparezca en
|
||||
# extensions.settings aunque su carpeta haya sido borrada. Editamos el JSON
|
||||
# con python3 para evitar ese comportamiento.
|
||||
if [[ ${#_removed[@]} -gt 0 ]]; then
|
||||
# Construir lista Python de IDs eliminados
|
||||
_py_ids_list=""
|
||||
for _id in "${_removed[@]}"; do
|
||||
_py_ids_list+="\"${_id}\","
|
||||
done
|
||||
_py_ids_list="[${_py_ids_list%,}]"
|
||||
|
||||
for _prefs_file in "$_DST/Default/Preferences" "$_DST/Default/Secure Preferences"; do
|
||||
if [[ -f "$_prefs_file" ]]; then
|
||||
python3 - "$_prefs_file" "$_py_ids_list" <<'PY' || \
|
||||
echo "prepare_chrome_profile: advertencia — no se pudieron purgar refs en $(basename "$_prefs_file")" >&2
|
||||
import sys, json
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
removed_ids = json.loads(sys.argv[2])
|
||||
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 1. extensions.settings.<id>
|
||||
ext_settings = data.get("extensions", {}).get("settings", {})
|
||||
for ext_id in removed_ids:
|
||||
ext_settings.pop(ext_id, None)
|
||||
|
||||
# 2. extensions.pinned_extensions (lista de IDs)
|
||||
pinned = data.get("extensions", {}).get("pinned_extensions", None)
|
||||
if isinstance(pinned, list):
|
||||
data["extensions"]["pinned_extensions"] = [
|
||||
pid for pid in pinned if pid not in removed_ids
|
||||
]
|
||||
|
||||
# 3. protection.macs.extensions.settings.<id> (Secure Preferences)
|
||||
try:
|
||||
mac_ext = data["protection"]["macs"]["extensions"]["settings"]
|
||||
for ext_id in removed_ids:
|
||||
mac_ext.pop(ext_id, None)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── emitir resultado JSON ─────────────────────────────────────────────────────
|
||||
_json_array() {
|
||||
# Convierte array bash en JSON array de strings
|
||||
local arr=("$@")
|
||||
local out="["
|
||||
local first=1
|
||||
for item in "${arr[@]}"; do
|
||||
if [[ $first -eq 1 ]]; then
|
||||
out+="\"$item\""
|
||||
first=0
|
||||
else
|
||||
out+=",\"$item\""
|
||||
fi
|
||||
done
|
||||
out+="]"
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
_kept_json="$(_json_array "${_kept[@]+"${_kept[@]}"}")"
|
||||
_removed_json="$(_json_array "${_removed[@]+"${_removed[@]}"}")"
|
||||
|
||||
printf '{"dst":"%s","kept":%s,"removed":%s}\n' \
|
||||
"$_DST_REAL" \
|
||||
"$_kept_json" \
|
||||
"$_removed_json"
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: restore_chrome_bookmarks
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "restore_chrome_bookmarks --backup-dir <ts-dir> [--user-data-dir <dir>] [--profile <name>]... [--dry-run]"
|
||||
description: "Restaura archivos Bookmarks de Chrome/Chromium desde un directorio de backup generado por backup_chrome_bookmarks hacia los perfiles destino en user-data-dir. Copia byte a byte con cp -p para preservar el checksum MD5 interno del archivo. Nunca parsea ni reserializa el JSON. Requiere que Chromium esté cerrado antes de ejecutar."
|
||||
tags: [navegator, chromium, bookmarks, restore, browser, scraping, profile]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/restore_chrome_bookmarks.sh"
|
||||
params:
|
||||
- name: --backup-dir
|
||||
desc: "Directorio de backup con timestamp generado por backup_chrome_bookmarks. Debe contener subdirectorios <profile>/Bookmarks. OBLIGATORIO."
|
||||
- name: --user-data-dir
|
||||
desc: "Ruta raíz del user-data-dir de Chrome/Chromium destino. Default: ~/.config/chromium"
|
||||
- name: --profile
|
||||
desc: "Nombre del perfil a restaurar (repetible, ej. Default, Profile 1). Si no se pasa ninguno se restauran TODOS los perfiles presentes en el backup-dir."
|
||||
- name: --dry-run
|
||||
desc: "Muestra qué archivos se copiarían y cuáles Bookmarks.bak se borrarían, sin tocar nada en disco."
|
||||
output: "JSON en stdout: {\"restored\": [{\"profile\": \"Default\", \"dst\": \"<path>\", \"bytes\": N}, ...]}. Exit 0 en éxito o dry-run. Errores a stderr con exit != 0."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# PASO 1 — cerrar Chromium (OBLIGATORIO en modo real)
|
||||
pkill -TERM chromium
|
||||
|
||||
# PASO 2 — restaurar todos los perfiles desde el backup más reciente
|
||||
source $HOME/fn_registry/bash/functions/browser/restore_chrome_bookmarks.sh
|
||||
restore_chrome_bookmarks \
|
||||
--user-data-dir "$HOME/.config/chromium" \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00"
|
||||
|
||||
# Restaurar solo un perfil concreto
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--profile Default
|
||||
|
||||
# Restaurar dos perfiles específicos
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--profile Default \
|
||||
--profile "Profile 1"
|
||||
|
||||
# Previsualizar sin tocar nada (no necesita Chromium cerrado)
|
||||
restore_chrome_bookmarks \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--dry-run
|
||||
|
||||
# Salida esperada:
|
||||
# {"restored":[{"profile":"Default","dst":"/home/enmanuel/.config/chromium/Default/Bookmarks","bytes":12453}]}
|
||||
```
|
||||
|
||||
También ejecutable directamente con `fn run`:
|
||||
|
||||
```bash
|
||||
cd $HOME/fn_registry
|
||||
./fn run restore_chrome_bookmarks_bash_browser -- \
|
||||
--backup-dir "$HOME/backups/chromium_bookmarks/2026-06-04T15:30:00" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala después de una sesión de scraping o automatización que haya alterado los bookmarks, o para recuperar bookmarks tras formatear/recrear un perfil de Chromium. Combínala con `backup_chrome_bookmarks` (que genera el `--backup-dir` con la estructura esperada) para tener un ciclo completo de backup/restore. También útil para propagar bookmarks de un perfil o PC a otro.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium DEBE estar cerrado** antes de ejecutar en modo real. Chromium mantiene los bookmarks en memoria y los reescribe al archivo `Bookmarks` al cerrar; si restauras con Chromium abierto, el proceso sobreescribirá tu restauración al cerrarse. La función lo comprueba con `pgrep -x chromium` y aborta con exit 2 si hay procesos vivos. En `--dry-run` este check se omite.
|
||||
- **Copia verbatim — nunca reserializar el JSON**. El archivo `Bookmarks` contiene un campo `checksum` con el MD5 del propio contenido JSON (calculado por Chromium internamente). Si se parsea y reserializa el JSON (aunque sea equivalente), el checksum queda inválido y Chromium descarta silenciosamente el archivo y regenera uno vacío. Esta función usa `cp -p` para garantizar que los bytes son idénticos al original.
|
||||
- **En Chromium 148 los bookmarks NO están bajo `super_mac` de Secure Preferences**. No es necesario tocar `Preferences` ni `Secure Preferences` al restaurar bookmarks (a diferencia de extensiones). La función solo opera sobre el archivo `Bookmarks`.
|
||||
- **`Bookmarks.bak` residual se borra**. Chromium crea `Bookmarks.bak` como copia de seguridad interna. Si existe antes de la restauración, esta función lo borra para que Chromium no lo use como fallback en lugar del archivo recién restaurado.
|
||||
- **El directorio destino del perfil se crea si no existe**. Si el perfil aún no tiene directorio en `user-data-dir`, se crea con `mkdir -p`. Chromium lo inicializará correctamente la primera vez que arranque con ese perfil.
|
||||
- **Opera por perfil**. Si no pasas `--profile`, restaura todos los perfiles presentes en el backup. Pasa `--profile` explícito para restaurar selectivamente y evitar sobreescribir perfiles sin querer.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito o dry-run completado |
|
||||
| 1 | Argumento inválido, backup-dir/user-data-dir no encontrado, o perfil no presente en backup |
|
||||
| 2 | Chromium está corriendo (solo en modo real) |
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# restore_chrome_bookmarks — restaura archivos Bookmarks de un backup generado por
|
||||
# backup_chrome_bookmarks hacia los perfiles destino en user-data-dir.
|
||||
# Copia byte a byte con cp -p (nunca parsea ni reserializa el JSON).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
restore_chrome_bookmarks() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _user_data_dir="${HOME}/.config/chromium"
|
||||
local _backup_dir=""
|
||||
local _profiles=()
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: restore_chrome_bookmarks --backup-dir <ts-dir>
|
||||
[--user-data-dir <dir>] [--profile <name>]... [--dry-run]
|
||||
|
||||
--user-data-dir Raíz de perfiles destino. Default: ~/.config/chromium
|
||||
--backup-dir Directorio de backup con timestamp generado por
|
||||
backup_chrome_bookmarks. Debe contener subdirectorios
|
||||
<profile>/Bookmarks. OBLIGATORIO.
|
||||
--profile <name> Perfil a restaurar (repetible). Si no se pasa ninguno
|
||||
se restauran TODOS los perfiles presentes en backup-dir.
|
||||
--dry-run Muestra qué se copiaría sin tocar nada.
|
||||
|
||||
Exit codes:
|
||||
0 éxito (o dry-run completado)
|
||||
1 error de argumento o validación
|
||||
2 chromium está corriendo (solo en modo real)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _user_data_dir="$2"; shift 2 ;;
|
||||
--backup-dir) _backup_dir="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "restore_chrome_bookmarks: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones ──────────────────────────────────────────────────────────
|
||||
if [[ -z "$_backup_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: --backup-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_backup_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: backup-dir no encontrado: ${_backup_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$_user_data_dir" ]]; then
|
||||
echo "restore_chrome_bookmarks: user-data-dir no encontrado: ${_user_data_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto (excepto en dry-run) ──
|
||||
# Por-udd, no global. Comprobamos por PID con comm=chromium (pgrep -x) y leemos su cmdline,
|
||||
# para NO auto-matchear el propio `grep`/`pgrep`: el path del udd contiene "chromium"
|
||||
# (~/.config/chromium-cdp), así que un `pgrep -af '[c]hromium' | grep <udd>` se detecta a sí mismo.
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _p _busy=0
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_user_data_dir"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "restore_chrome_bookmarks: hay un chromium con este user-data-dir abierto — ciérralo antes de restaurar:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo "(Chromium reescribe Bookmarks desde memoria al cerrar y desharía la restauración)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── determinar perfiles a restaurar ───────────────────────────────────────
|
||||
local _target_profiles=()
|
||||
|
||||
if [[ ${#_profiles[@]} -gt 0 ]]; then
|
||||
# Perfiles explícitos: verificar que existen en el backup
|
||||
local _p
|
||||
for _p in "${_profiles[@]}"; do
|
||||
if [[ ! -f "${_backup_dir}/${_p}/Bookmarks" ]]; then
|
||||
echo "restore_chrome_bookmarks: backup no contiene perfil '${_p}': ${_backup_dir}/${_p}/Bookmarks" >&2
|
||||
return 1
|
||||
fi
|
||||
_target_profiles+=("$_p")
|
||||
done
|
||||
else
|
||||
# Autodescubrir todos los perfiles en el backup
|
||||
local _profile_path
|
||||
while IFS= read -r -d '' _profile_path; do
|
||||
local _pname
|
||||
_pname="$(basename "$(dirname "$_profile_path")")"
|
||||
_target_profiles+=("$_pname")
|
||||
done < <(find "$_backup_dir" -mindepth 2 -maxdepth 2 -name "Bookmarks" -print0 | sort -z)
|
||||
|
||||
if [[ ${#_target_profiles[@]} -eq 0 ]]; then
|
||||
echo "restore_chrome_bookmarks: no se encontraron archivos Bookmarks en: ${_backup_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── restaurar cada perfil ─────────────────────────────────────────────────
|
||||
local _restored_json=""
|
||||
local _first=1
|
||||
|
||||
local _prof
|
||||
for _prof in "${_target_profiles[@]}"; do
|
||||
local _src="${_backup_dir}/${_prof}/Bookmarks"
|
||||
local _dst_dir="${_user_data_dir}/${_prof}"
|
||||
local _dst="${_dst_dir}/Bookmarks"
|
||||
local _dst_bak="${_dst_dir}/Bookmarks.bak"
|
||||
|
||||
# Tamaño del archivo fuente para el JSON de salida
|
||||
local _bytes=0
|
||||
if [[ -f "$_src" ]]; then
|
||||
_bytes="$(wc -c < "$_src")"
|
||||
# Eliminar espacios que wc puede añadir en algunas plataformas
|
||||
_bytes="${_bytes// /}"
|
||||
fi
|
||||
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== restore_chrome_bookmarks DRY-RUN ===" >&2
|
||||
echo " Perfil : ${_prof}" >&2
|
||||
echo " src : ${_src}" >&2
|
||||
echo " dst : ${_dst}" >&2
|
||||
echo " bytes : ${_bytes}" >&2
|
||||
if [[ -f "$_dst_bak" ]]; then
|
||||
echo " .bak : borraría ${_dst_bak}" >&2
|
||||
fi
|
||||
else
|
||||
# Crear directorio destino si no existe
|
||||
mkdir -p "$_dst_dir"
|
||||
|
||||
# Copiar byte a byte preservando timestamps (NUNCA reserializar)
|
||||
cp -p "$_src" "$_dst"
|
||||
|
||||
# Borrar Bookmarks.bak residual si existe
|
||||
if [[ -f "$_dst_bak" ]]; then
|
||||
rm -f "$_dst_bak"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Construir fragmento JSON para este perfil
|
||||
local _entry
|
||||
_entry="$(printf '{"profile":"%s","dst":"%s","bytes":%s}' \
|
||||
"$_prof" "$_dst" "$_bytes")"
|
||||
|
||||
if [[ $_first -eq 1 ]]; then
|
||||
_restored_json="${_entry}"
|
||||
_first=0
|
||||
else
|
||||
_restored_json+=",$_entry"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── emitir resultado JSON ─────────────────────────────────────────────────
|
||||
printf '{"restored":[%s]}\n' "$_restored_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
restore_chrome_bookmarks "$@"
|
||||
fi
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: set_chrome_profile_appearance
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: browser
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name> [--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]"
|
||||
description: "Personaliza la apariencia visual de un perfil Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen PNG/JPG custom, y/o un color de acento (hex #rrggbb). Con --color aplica el tinte tanto al círculo del avatar en Local State (profile_highlight_color, profile_color_seed, default_avatar_fill_color) como al tema completo del navegador en el Preferences del perfil (browser.theme.user_color2, browser_color_variant, extensions.theme.system_theme), tiñendo toolbar, frame, barra de pestañas y omnibox. Requiere que Chromium esté cerrado sobre el user-data-dir. Hace backup de Local State y Preferences antes de escribir y valida el JSON resultante."
|
||||
tags: [navegator, chromium, profile, browser, cdp, scraping, appearance, avatar, color]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/browser/set_chrome_profile_appearance.sh"
|
||||
params:
|
||||
- name: --user-data-dir
|
||||
desc: "Raíz del user-data-dir de Chrome/Chromium donde vive el perfil. El directorio y Local State deben existir. Obligatorio."
|
||||
- name: --profile
|
||||
desc: "Nombre de la carpeta del perfil dentro de user-data-dir, por ejemplo: Default, Automation, \"Profile 1\". El perfil debe existir previamente en info_cache de Local State. Obligatorio."
|
||||
- name: --avatar
|
||||
desc: "Índice entero 0..55 del avatar built-in de Chrome (56 avatares: animales, objetos, personas) o ruta absoluta/relativa a un archivo PNG/JPG para avatar custom. Con índice: sets avatar_icon=IDR_PROFILE_AVATAR_<N> e is_using_default_avatar=true. Con imagen: copia el archivo al perfil como 'Google Profile Picture.png' y sets is_using_default_avatar=false. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --color
|
||||
desc: "Color de acento del perfil en hex #rrggbb, con o sin el '#' inicial. Se convierte a int32 con signo en formato ARGB 0xFFRRGGBB. Aplica el color en dos lugares: (1) Local State info_cache (profile_highlight_color, profile_color_seed, default_avatar_fill_color) para el círculo del avatar; (2) Preferences del perfil (browser.theme.user_color2 + browser_color_variant + extensions.theme.system_theme=0) para teñir toolbar, frame, barra de pestañas y omnibox. Opcional; al menos uno de --avatar o --color debe darse."
|
||||
- name: --variant
|
||||
desc: "Intensidad del tema de color aplicado al navegador (browser_color_variant). Entero 0..4: 0=system, 1=tonal_spot, 2=neutral, 3=vibrant (default), 4=expressive. Valores más altos dan tintes más saturados e identificables. Solo tiene efecto cuando se usa --color. Opcional."
|
||||
- name: --dry-run
|
||||
desc: "Describe las acciones que se ejecutarían (campos a modificar en Local State y Preferences, conversión de color, ruta del Preferences) sin escribir nada ni verificar si Chromium está corriendo. Emite JSON de resultado con dry_run:true."
|
||||
output: "JSON en stdout con los campos resultantes del perfil: {\"profile\":\"<dir>\",\"avatar_icon\":\"...\",\"is_using_default_avatar\":true|false,\"profile_highlight_color\":<int>,\"profile_color_seed\":<int>,\"default_avatar_fill_color\":<int>,\"theme_applied\":true|false,\"variant\":<int>,\"preferences_path\":\"...\",\"browser_theme_user_color2\":<int>,\"browser_theme_color_variant\":<int>,\"extensions_theme_system_theme\":<int>,\"backup\":\"Local State.bak.YYYYMMDD\"}. En dry-run: {\"profile\":\"...\",\"avatar_applied\":true|false,\"color_applied\":true|false,\"theme_applied\":true|false,\"variant\":<int>,\"dry_run\":true}. Mensajes de diagnóstico a stderr. Exit 0 en éxito."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source $HOME/fn_registry/bash/functions/browser/set_chrome_profile_appearance.sh
|
||||
|
||||
# Asignar avatar #30 y tinte verde a toolbar/frame/omnibox del perfil Automation
|
||||
# (verde #16a34a tiñe toda la chrome del navegador, no solo el círculo del avatar)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a"
|
||||
# Salida JSON incluye: theme_applied:true, variant:3, browser_theme_user_color2:-15293622
|
||||
|
||||
# Color con intensidad personalizada (expressive = máxima saturación)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Scraping \
|
||||
--color "#1f6feb" \
|
||||
--variant 4
|
||||
|
||||
# Solo cambiar avatar (no toca Preferences del perfil)
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile "Profile 1" \
|
||||
--avatar 5
|
||||
|
||||
# Dry-run: ver qué se aplicaría en Local State y Preferences sin escribir
|
||||
set_chrome_profile_appearance \
|
||||
--user-data-dir ~/.config/chromium-cdp \
|
||||
--profile Automation \
|
||||
--avatar 30 \
|
||||
--color "#16a34a" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala para diferenciar visualmente los perfiles de un user-data-dir de automatización — un color y avatar distintos por perfil hacen inmediata la identificación en el selector de Chrome Y en la chrome del navegador (toolbar/frame visible mientras navega). Ejecútala justo después de `create_chrome_profile` (con `--no-launch`) o como paso independiente de personalización batch antes de lanzar sesiones CDP. Si solo quieres teñir el círculo del avatar (sin el tema), basta esta función; si quieres el tinte completo del navegador (lo más identificable), pasa `--color`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Chromium debe estar cerrado**: Chrome reescribe `Local State` y `Preferences` completos desde memoria al cerrar; si se ejecuta mientras hay un proceso chromium vivo sobre el mismo user-data-dir, Chrome sobreescribirá los cambios al salir. La función detecta esto con `pgrep -x chromium` filtrando por cmdline y sale con exit 2 antes de modificar nada. Usa `pkill -TERM chromium` para cerrar y espera unos segundos.
|
||||
- **El tema se escribe en Preferences del perfil, distinto de Local State**: los cambios de color al avatar van en `<user-data-dir>/Local State` (global a todos los perfiles); los cambios de tema del navegador van en `<user-data-dir>/<profile_dir>/Preferences` (específico de cada perfil). La función hace backup de ambos archivos por separado antes de tocarlos.
|
||||
- **El perfil debe existir en info_cache**: esta función personaliza perfiles existentes; no los crea. Usa `create_chrome_profile` primero (con `--no-launch` basta para que aparezca en Local State) y luego `set_chrome_profile_appearance`.
|
||||
- **color es int32 con signo en ARGB**: Chrome almacena el color como entero con signo de 32 bits en formato `0xAARRGGBB`. Un color como `#16a34a` (verde) da ARGB `0xFF16A34A` → signed int32 `-15293622`. La función hace la conversión internamente; tú pasas siempre hex `#rrggbb`.
|
||||
- **En modo oscuro del sistema el tinte sale más apagado**: en temas oscuros del sistema el color se mezcla con el fondo oscuro y queda menos saturado. Para compensar, usa `--variant 3` (vibrant, default) o `--variant 4` (expressive); valores bajos como 1 o 2 pueden resultar casi imperceptibles en modo oscuro.
|
||||
- **`extensions.theme.system_theme` se fuerza a 0**: si el perfil usaba el tema GTK del sistema (`system_theme=1`), el GTK puede ignorar el `user_color`. Esta función lo fuerza a 0 (tema propio de Chrome) para que el `user_color2` tenga efecto. Si quieres devolver el perfil al tema del sistema, tendrás que resetear `system_theme` manualmente.
|
||||
- **Avatar custom (imagen) es best-effort**: el campo `gaia_picture_file_name` y `is_using_default_avatar=false` se aplican correctamente en Local State y la imagen se copia al directorio del perfil. Sin embargo, Chrome puede ignorar la foto de perfil en perfiles sin sesión Google activa (Chromium sin cuenta). El camino robusto y garantizado es usar el índice built-in (`--avatar 0..55`): 56 avatares (animales, objetos, personas) son más que suficientes para diferenciar perfiles de automatización.
|
||||
- **Backup diario**: se crea `Local State.bak.YYYYMMDD` y `Preferences.bak.YYYYMMDD` antes de cualquier escritura. Si ya existen los backups del día no se sobreescriben. Si el JSON resultante es inválido, se restaura automáticamente el backup correspondiente.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Código | Significado |
|
||||
|--------|------------|
|
||||
| 0 | Éxito |
|
||||
| 1 | Argumento obligatorio faltante, rango inválido o archivo de imagen no encontrado |
|
||||
| 2 | Lock: hay un chromium usando el mismo user-data-dir |
|
||||
| 3 | El perfil no existe en info_cache de Local State |
|
||||
| 4 | Error editando Local State o Preferences (JSON inválido tras escritura, restaurado backup) |
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.1.0 (2026-06-06) — --color ahora aplica también el tema del navegador (toolbar/frame/omnibox) escribiendo browser.theme.user_color2 + browser_color_variant en el Preferences del perfil, no solo el color del avatar en Local State. Nuevo flag --variant (0..4, default 3 vibrant). Verificado con captura en Chromium 148.
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env bash
|
||||
# set_chrome_profile_appearance — personaliza la apariencia visual de un perfil
|
||||
# Chrome/Chromium existente: asigna un avatar built-in (índice 0..55) o una imagen
|
||||
# PNG/JPG custom, y/o un color de acento (hex #rrggbb). Edita Local State Y el
|
||||
# Preferences del perfil (browser.theme.* para teñir toolbar/frame/omnibox).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
set_chrome_profile_appearance() {
|
||||
# ── defaults ──────────────────────────────────────────────────────────────
|
||||
local _udd=""
|
||||
local _profile_dir=""
|
||||
local _avatar=""
|
||||
local _color=""
|
||||
local _variant=3
|
||||
local _dry_run=0
|
||||
|
||||
# ── parse args ─────────────────────────────────────────────────────────────
|
||||
_usage() {
|
||||
cat >&2 <<'EOF'
|
||||
Usage: set_chrome_profile_appearance --user-data-dir <dir> --profile <dir-name>
|
||||
[--avatar <N|ruta.png>] [--color <#rrggbb>] [--variant <0..4>] [--dry-run]
|
||||
|
||||
--user-data-dir Raíz del user-data-dir de Chrome/Chromium (obligatorio).
|
||||
--profile Nombre de la carpeta del perfil, ej: Default, Automation,
|
||||
"Profile 1" (obligatorio). El perfil debe existir.
|
||||
--avatar Índice entero 0..55 del avatar built-in de Chrome, o ruta a
|
||||
un archivo PNG/JPG para avatar custom (opcional).
|
||||
--color Color de acento del perfil en formato hex #rrggbb, con o sin
|
||||
el '#' inicial (opcional). Aplica el color tanto al círculo
|
||||
del avatar (Local State) como al tema del navegador
|
||||
(toolbar/frame/omnibox via Preferences del perfil).
|
||||
--variant Intensidad del tema de color: 0=system, 1=tonal_spot,
|
||||
2=neutral, 3=vibrant (default), 4=expressive. Solo tiene
|
||||
efecto cuando se usa --color.
|
||||
--dry-run Describe las acciones sin modificar nada.
|
||||
|
||||
Al menos uno de --avatar o --color debe indicarse.
|
||||
|
||||
Exit codes:
|
||||
0 éxito
|
||||
1 error de argumento o validación
|
||||
2 lock: hay un chromium corriendo con este user-data-dir
|
||||
3 el perfil no existe en info_cache de Local State
|
||||
4 error editando Local State o Preferences (JSON inválido tras escritura)
|
||||
EOF
|
||||
return 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _udd="$2"; shift 2 ;;
|
||||
--profile) _profile_dir="$2"; shift 2 ;;
|
||||
--avatar) _avatar="$2"; shift 2 ;;
|
||||
--color) _color="$2"; shift 2 ;;
|
||||
--variant) _variant="$2"; shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
-h|--help) _usage; return 0 ;;
|
||||
*) echo "set_chrome_profile_appearance: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── validaciones obligatorias ──────────────────────────────────────────────
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: --user-data-dir es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_profile_dir" ]]; then
|
||||
echo "set_chrome_profile_appearance: --profile es obligatorio" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$_avatar" && -z "$_color" ]]; then
|
||||
echo "set_chrome_profile_appearance: al menos --avatar o --color debe indicarse" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validar --variant
|
||||
if ! [[ "$_variant" =~ ^[0-4]$ ]]; then
|
||||
echo "set_chrome_profile_appearance: --variant debe ser un entero 0..4, recibido: ${_variant}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Expandir ~ en el user-data-dir
|
||||
_udd="${_udd/#\~/$HOME}"
|
||||
|
||||
local _local_state="${_udd}/Local State"
|
||||
|
||||
# Verificar que user-data-dir y Local State existen
|
||||
if [[ ! -d "$_udd" ]]; then
|
||||
echo "set_chrome_profile_appearance: user-data-dir no encontrado: ${_udd}" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$_local_state" ]]; then
|
||||
echo "set_chrome_profile_appearance: Local State no encontrado: ${_local_state}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── validar --avatar ──────────────────────────────────────────────────────
|
||||
local _avatar_index=-1
|
||||
local _avatar_image_path=""
|
||||
|
||||
if [[ -n "$_avatar" ]]; then
|
||||
if [[ "$_avatar" =~ ^[0-9]+$ ]]; then
|
||||
# Índice built-in
|
||||
_avatar_index=$(( _avatar ))
|
||||
if [[ $_avatar_index -lt 0 || $_avatar_index -gt 55 ]]; then
|
||||
echo "set_chrome_profile_appearance: índice de avatar fuera de rango (0..55): ${_avatar}" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Ruta a imagen custom
|
||||
local _img_path="${_avatar/#\~/$HOME}"
|
||||
if [[ ! -f "$_img_path" ]]; then
|
||||
echo "set_chrome_profile_appearance: archivo de imagen no encontrado: ${_img_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
_avatar_image_path="$_img_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── validar --color ───────────────────────────────────────────────────────
|
||||
local _color_hex=""
|
||||
if [[ -n "$_color" ]]; then
|
||||
_color_hex="${_color/#\#/}" # quitar # inicial si lo hay
|
||||
if ! [[ "$_color_hex" =~ ^[0-9a-fA-F]{6}$ ]]; then
|
||||
echo "set_chrome_profile_appearance: color hex inválido (espera rrggbb): ${_color}" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── guard: ningún chromium debe tener ESTE user-data-dir abierto ──────────
|
||||
# pgrep -x chromium lista solo procesos cuyo comm es exactamente "chromium",
|
||||
# nunca grep/pgrep/bash. Así evitamos auto-matchear el propio script cuando
|
||||
# el path del udd contiene "chromium" (p.ej. ~/.config/chromium-cdp).
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _p _busy=0
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd"; then
|
||||
_busy=1; break
|
||||
fi
|
||||
done
|
||||
if [[ $_busy -eq 1 ]]; then
|
||||
echo "set_chrome_profile_appearance: hay un chromium corriendo con este user-data-dir — ciérralo primero:" >&2
|
||||
echo " pkill -TERM chromium" >&2
|
||||
echo " (Chrome reescribe Local State y Preferences al cerrar y pierde los cambios)" >&2
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── verificar que el perfil existe en info_cache ──────────────────────────
|
||||
if [[ $_dry_run -eq 0 ]]; then
|
||||
local _profile_exists
|
||||
_profile_exists="$(python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
print('yes' if sys.argv[2] in ic else 'no')
|
||||
" "$_local_state" "$_profile_dir" 2>/dev/null || echo "no")"
|
||||
if [[ "$_profile_exists" != "yes" ]]; then
|
||||
echo "set_chrome_profile_appearance: perfil '${_profile_dir}' no existe en info_cache de Local State" >&2
|
||||
echo " Perfiles disponibles:" >&2
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
ic = data.get('profile', {}).get('info_cache', {})
|
||||
for k in ic: print(' ', k)
|
||||
" "$_local_state" >&2 2>/dev/null || true
|
||||
return 3
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── modo dry-run ──────────────────────────────────────────────────────────
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo "=== set_chrome_profile_appearance DRY-RUN ===" >&2
|
||||
echo " user-data-dir : ${_udd}" >&2
|
||||
echo " profile : ${_profile_dir}" >&2
|
||||
if [[ $_avatar_index -ge 0 ]]; then
|
||||
echo " avatar : built-in #${_avatar_index} → avatar_icon=chrome://theme/IDR_PROFILE_AVATAR_${_avatar_index}" >&2
|
||||
echo " is_using_default_avatar=true" >&2
|
||||
elif [[ -n "$_avatar_image_path" ]]; then
|
||||
local _dest_img="${_udd}/${_profile_dir}/Google Profile Picture.png"
|
||||
echo " avatar : imagen custom ${_avatar_image_path}" >&2
|
||||
echo " copiaría a ${_dest_img}" >&2
|
||||
echo " is_using_default_avatar=false" >&2
|
||||
echo " gaia_picture_file_name=Google Profile Picture.png" >&2
|
||||
fi
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
local _signed_preview
|
||||
_signed_preview="$(python3 -c "
|
||||
rgb = int('${_color_hex}', 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
print(signed)
|
||||
" 2>/dev/null || echo '?')"
|
||||
echo " color : #${_color_hex} → signed int32 ${_signed_preview}" >&2
|
||||
echo " Local State: profile_highlight_color, profile_color_seed, default_avatar_fill_color" >&2
|
||||
echo " Preferences: browser.theme.user_color2=${_signed_preview}, browser_color_variant=${_variant}, is_grayscale2=false" >&2
|
||||
echo " Preferences: extensions.theme.system_theme=0" >&2
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
echo " Preferences : ${_prefs_path}" >&2
|
||||
fi
|
||||
echo " Local State : ${_local_state}" >&2
|
||||
printf '{"profile":"%s","avatar_applied":%s,"color_applied":%s,"theme_applied":%s,"variant":%d,"dry_run":true}\n' \
|
||||
"$_profile_dir" \
|
||||
"$([[ -n "$_avatar" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$([[ -n "$_color_hex" ]] && echo 'true' || echo 'false')" \
|
||||
"$_variant"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── backup de Local State (no sobreescribir el del mismo día) ────────────
|
||||
local _today
|
||||
_today="$(date +%Y%m%d)"
|
||||
local _backup="${_local_state}.bak.${_today}"
|
||||
if [[ ! -f "$_backup" ]]; then
|
||||
cp "$_local_state" "$_backup"
|
||||
fi
|
||||
|
||||
# ── copiar imagen custom si es necesario ──────────────────────────────────
|
||||
local _copy_image_done=false
|
||||
if [[ -n "$_avatar_image_path" ]]; then
|
||||
local _profile_path="${_udd}/${_profile_dir}"
|
||||
mkdir -p "$_profile_path"
|
||||
cp "$_avatar_image_path" "${_profile_path}/Google Profile Picture.png"
|
||||
_copy_image_done=true
|
||||
fi
|
||||
|
||||
# ── editar Local State con python3 ────────────────────────────────────────
|
||||
if ! python3 - \
|
||||
"$_local_state" \
|
||||
"$_profile_dir" \
|
||||
"${_avatar_index}" \
|
||||
"${_avatar_image_path}" \
|
||||
"${_color_hex}" <<'PY'; then
|
||||
import sys, json
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
avatar_index = int(sys.argv[3]) # -1 = no cambiar avatar
|
||||
avatar_img = sys.argv[4] # "" = no usar imagen
|
||||
color_hex = sys.argv[5] # "" = no cambiar color
|
||||
|
||||
with open(ls_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
profile_section = data.setdefault("profile", {})
|
||||
info_cache = profile_section.setdefault("info_cache", {})
|
||||
|
||||
# El perfil debe existir (ya validado en bash, pero doble check)
|
||||
if prof_dir not in info_cache:
|
||||
print(f"error: perfil '{prof_dir}' no existe en info_cache", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
entry = info_cache[prof_dir]
|
||||
|
||||
# ── Avatar ────────────────────────────────────────────────────────────────────
|
||||
if avatar_index >= 0:
|
||||
# Avatar built-in: IDR_PROFILE_AVATAR_<N>
|
||||
entry["avatar_icon"] = f"chrome://theme/IDR_PROFILE_AVATAR_{avatar_index}"
|
||||
entry["is_using_default_avatar"] = True
|
||||
elif avatar_img:
|
||||
# Avatar custom imagen: Chrome necesita gaia_picture_file_name
|
||||
entry["avatar_icon"] = "chrome://theme/IDR_PROFILE_AVATAR_0"
|
||||
entry["is_using_default_avatar"] = False
|
||||
entry["gaia_picture_file_name"] = "Google Profile Picture.png"
|
||||
|
||||
# ── Color ─────────────────────────────────────────────────────────────────────
|
||||
if color_hex:
|
||||
rgb = int(color_hex, 16) # 0xRRGGBB
|
||||
argb = 0xFF000000 | rgb # alpha=FF opaco → 0xFFRRGGBB
|
||||
# Convertir a int32 con signo (Python usa enteros arbitrarios)
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
entry["profile_highlight_color"] = signed
|
||||
entry["profile_color_seed"] = signed
|
||||
entry["default_avatar_fill_color"] = signed
|
||||
|
||||
with open(ls_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Local State con python3" >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── validar JSON de Local State tras escritura ────────────────────────────
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_local_state" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Local State; restaurando backup" >&2
|
||||
cp "$_backup" "$_local_state"
|
||||
return 4
|
||||
fi
|
||||
|
||||
# ── editar Preferences del perfil (browser.theme.*) si hay color ─────────
|
||||
local _prefs_path="${_udd}/${_profile_dir}/Preferences"
|
||||
local _prefs_backup=""
|
||||
local _theme_applied=false
|
||||
|
||||
if [[ -n "$_color_hex" ]]; then
|
||||
_theme_applied=true
|
||||
|
||||
# Backup de Preferences antes de escribir (mismo patrón que Local State)
|
||||
if [[ -f "$_prefs_path" ]]; then
|
||||
_prefs_backup="${_prefs_path}.bak.${_today}"
|
||||
if [[ ! -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_path" "$_prefs_backup"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Editar/crear Preferences con python3
|
||||
if ! python3 - \
|
||||
"$_prefs_path" \
|
||||
"${_color_hex}" \
|
||||
"${_variant}" <<'PY'; then
|
||||
import sys, json, os
|
||||
|
||||
prefs_path = sys.argv[1]
|
||||
color_hex = sys.argv[2]
|
||||
variant = int(sys.argv[3])
|
||||
|
||||
# Calcular el signed int32 ARGB
|
||||
rgb = int(color_hex, 16)
|
||||
argb = 0xFF000000 | rgb
|
||||
signed = argb - 0x100000000 if argb >= 0x80000000 else argb
|
||||
|
||||
# Cargar Preferences existente o arrancar desde vacío
|
||||
if os.path.isfile(prefs_path):
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
# ── browser.theme.* ──────────────────────────────────────────────────────────
|
||||
browser = data.setdefault("browser", {})
|
||||
theme = browser.setdefault("theme", {})
|
||||
|
||||
# Claves modernas (sufijo "2") — verificadas en Chromium 148
|
||||
theme["user_color2"] = signed
|
||||
theme["browser_color_variant"] = variant
|
||||
theme["is_grayscale2"] = False
|
||||
|
||||
# Claves legacy (sin sufijo "2") — compatibilidad con versiones anteriores
|
||||
theme["user_color"] = signed
|
||||
theme["color_variant"] = variant
|
||||
theme["is_grayscale"] = False
|
||||
|
||||
# ── extensions.theme.system_theme = 0 ────────────────────────────────────────
|
||||
# 0=color propio, 1=GTK, 2=Qt. Forzar 0 para que el user_color tenga efecto.
|
||||
extensions = data.setdefault("extensions", {})
|
||||
ext_theme = extensions.setdefault("theme", {})
|
||||
ext_theme["system_theme"] = 0
|
||||
|
||||
# Escribir directorio si no existe (perfil recién creado sin arrancar)
|
||||
os.makedirs(os.path.dirname(prefs_path), exist_ok=True)
|
||||
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
PY
|
||||
echo "set_chrome_profile_appearance: error editando Preferences con python3" >&2
|
||||
# Restaurar Preferences si teníamos backup
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
elif [[ -f "$_prefs_path" ]]; then
|
||||
rm -f "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Validar JSON de Preferences tras escritura
|
||||
if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$_prefs_path" 2>/dev/null; then
|
||||
echo "set_chrome_profile_appearance: JSON inválido tras escribir Preferences; restaurando backup" >&2
|
||||
if [[ -n "$_prefs_backup" && -f "$_prefs_backup" ]]; then
|
||||
cp "$_prefs_backup" "$_prefs_path"
|
||||
fi
|
||||
return 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── leer valores resultantes para el JSON de salida ───────────────────────
|
||||
local _result_json
|
||||
_result_json="$(python3 - "$_local_state" "$_profile_dir" "$_prefs_path" "$_theme_applied" "$_variant" <<'PY'
|
||||
import json, sys, os
|
||||
|
||||
ls_path = sys.argv[1]
|
||||
prof_dir = sys.argv[2]
|
||||
prefs_path = sys.argv[3]
|
||||
theme_applied = sys.argv[4] == "true"
|
||||
variant = int(sys.argv[5])
|
||||
|
||||
data = json.load(open(ls_path))
|
||||
entry = data.get("profile", {}).get("info_cache", {}).get(prof_dir, {})
|
||||
|
||||
out = {
|
||||
"profile": prof_dir,
|
||||
"avatar_icon": entry.get("avatar_icon", ""),
|
||||
"is_using_default_avatar": entry.get("is_using_default_avatar", True),
|
||||
"profile_highlight_color": entry.get("profile_highlight_color", 0),
|
||||
"profile_color_seed": entry.get("profile_color_seed", 0),
|
||||
"default_avatar_fill_color": entry.get("default_avatar_fill_color", 0),
|
||||
"theme_applied": theme_applied,
|
||||
"variant": variant,
|
||||
"preferences_path": prefs_path if theme_applied else "",
|
||||
"backup": "Local State.bak." + __import__("datetime").date.today().strftime("%Y%m%d"),
|
||||
}
|
||||
|
||||
# Añadir valores de theme si se aplicó
|
||||
if theme_applied and os.path.isfile(prefs_path):
|
||||
try:
|
||||
prefs = json.load(open(prefs_path))
|
||||
bt = prefs.get("browser", {}).get("theme", {})
|
||||
out["browser_theme_user_color2"] = bt.get("user_color2", 0)
|
||||
out["browser_theme_color_variant"] = bt.get("browser_color_variant", 0)
|
||||
out["extensions_theme_system_theme"] = prefs.get("extensions", {}).get("theme", {}).get("system_theme", -1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(json.dumps(out, separators=(",",":")))
|
||||
PY
|
||||
)"
|
||||
|
||||
echo "$_result_json"
|
||||
}
|
||||
|
||||
# ── auto-ejecución ────────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set_chrome_profile_appearance "$@"
|
||||
fi
|
||||
@@ -3,17 +3,17 @@ name: adb_wsl
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]"
|
||||
description: "Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador."
|
||||
tags: ["android", "adb", "wsl", "windows"]
|
||||
signature: "source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]"
|
||||
description: "Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot."
|
||||
tags: ["android", "adb", "linux", "emulator", "wsl"]
|
||||
params:
|
||||
- name: ADB
|
||||
desc: "Env var opcional. Path absoluto a adb.exe. Si no se fija, se construye desde ANDROID_SDK_WIN o el default /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
|
||||
- name: ANDROID_SDK_WIN
|
||||
desc: "Env var opcional. Raiz del Android SDK montado en WSL. Default: /mnt/c/Users/lucas/AppData/Local/Android/Sdk."
|
||||
output: "Source-able shell helpers: adb_run, adb_devices, adb_wsl_to_win, adb_wait_boot. Define ADB env var apuntando a Windows adb.exe via ANDROID_SDK_WIN."
|
||||
desc: "Env var opcional. Path absoluto al binario adb (override explicito). Si no se fija, se resuelve Linux-first: $ANDROID_HOME/platform-tools/adb, luego adb del PATH, luego adb.exe si WSL2."
|
||||
- name: ANDROID_HOME
|
||||
desc: "Env var opcional. Raiz del Android SDK nativo. Si esta presente, se usa $ANDROID_HOME/platform-tools/adb. Tambien se acepta ANDROID_SDK_ROOT."
|
||||
output: "Source-able shell helpers: adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot, adb_wsl_to_win. Resuelve y fija la env var ADB al binario adb disponible."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -26,24 +26,33 @@ test_file_path: ""
|
||||
file_path: "bash/functions/infra/adb_wsl.sh"
|
||||
---
|
||||
|
||||
## Uso
|
||||
## Cuando usarla
|
||||
|
||||
Sourcéala como capa base de cualquier script que hable con un device o emulador Android via adb. Es la dependencia comun de todo el toolbelt android del registry (`android_screenshot`, `android_input_*`, `android_logcat`, `android_app_*`, `android_push/pull`). En Linux nativo resuelve el adb del SDK automaticamente; no hace falta configurar nada si `ANDROID_HOME` esta exportado (o `adb` esta en el PATH).
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Sourcear (usa SDK default)
|
||||
# Linux nativo: con el SDK instalado y ANDROID_HOME exportado, resuelve solo.
|
||||
source ~/android-sdk/env.sh
|
||||
source bash/functions/infra/adb_wsl.sh
|
||||
adb_devices
|
||||
# List of devices attached
|
||||
# emulator-5554 device
|
||||
|
||||
# Sourcear con SDK custom
|
||||
ANDROID_SDK_WIN=/mnt/d/Android/Sdk source bash/functions/infra/adb_wsl.sh
|
||||
# Fijar binario adb explicito (override)
|
||||
ADB=/opt/android/platform-tools/adb source bash/functions/infra/adb_wsl.sh
|
||||
|
||||
# Sourcear con binario fijo
|
||||
ADB=/mnt/c/my/tools/adb.exe source bash/functions/infra/adb_wsl.sh
|
||||
# Smoke test
|
||||
bash bash/functions/infra/adb_wsl.sh --self-test
|
||||
# Android Debug Bridge version 1.0.41
|
||||
```
|
||||
|
||||
## Funciones expuestas
|
||||
|
||||
### `adb_run "<args...>"`
|
||||
|
||||
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de `adb.exe`.
|
||||
Ejecuta `$ADB` con los argumentos dados. Retorna el exit code de adb.
|
||||
|
||||
```bash
|
||||
adb_run shell ls /sdcard/
|
||||
@@ -54,45 +63,34 @@ adb_run install app.apk
|
||||
|
||||
Alias de `adb_run devices`. Lista dispositivos/emuladores conectados.
|
||||
|
||||
```bash
|
||||
adb_devices
|
||||
# List of devices attached
|
||||
# emulator-5554 device
|
||||
```
|
||||
### `adb_pick_serial [--serial <S>] [...]`
|
||||
|
||||
### `adb_wsl_to_win <path_wsl>`
|
||||
|
||||
Convierte un path WSL a formato Windows con `wslpath -w`. Si `wslpath` no está disponible retorna el path sin convertir.
|
||||
Resuelve el serial a usar (multi-device). Lee `--serial X` de los args y setea los globals `ADB_PICK_SERIAL` y `ADB_PICK_REST`. Si no se pasa, autoselecciona el primer device/emulador conectado.
|
||||
|
||||
```bash
|
||||
win_path=$(adb_wsl_to_win /home/lucas/proyecto/app.apk)
|
||||
# C:\Users\lucas\AppData\Local\... (o la ruta Windows equivalente)
|
||||
adb_run install "$win_path"
|
||||
adb_pick_serial "$@" || { echo "no device" >&2; exit 3; }
|
||||
serial="$ADB_PICK_SERIAL"; set -- "${ADB_PICK_REST[@]}"
|
||||
```
|
||||
|
||||
### `adb_s <serial> <args...>`
|
||||
|
||||
Atajo de `adb_run -s <serial> <args...>` para multi-device.
|
||||
|
||||
### `adb_wait_boot [timeout_s]`
|
||||
|
||||
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Útil tras lanzar un AVD en CI.
|
||||
Espera a que el emulador/dispositivo complete el boot (`sys.boot_completed = 1`). Polling cada 3s. Retorna `0` si bootó, `1` si timeout (default 120s).
|
||||
|
||||
```bash
|
||||
adb_wait_boot # timeout 120s
|
||||
adb_wait_boot 60 # timeout 60s
|
||||
```
|
||||
### `adb_wsl_to_win <path_wsl>`
|
||||
|
||||
Retorna `0` si el boot se completó, `1` si expiró el timeout.
|
||||
Legacy WSL: convierte path WSL→Windows con `wslpath -w`. En Linux nativo (sin `wslpath`) devuelve el path tal cual.
|
||||
|
||||
## Smoke test
|
||||
## Gotchas
|
||||
|
||||
```bash
|
||||
bash bash/functions/infra/adb_wsl.sh --self-test
|
||||
# OK
|
||||
```
|
||||
- **Linux-first.** El default ya NO es Windows. Resolucion: `$ADB` → `$ANDROID_HOME/platform-tools/adb` → `adb` del PATH → (solo si `/proc/version` indica WSL2) `adb.exe`. En un PC Linux con el SDK instalado funciona sin configurar nada.
|
||||
- **Necesita el SDK o adb en PATH.** Si no encuentra adb aborta con mensaje a stderr. Instala con `fn run install_android_sdk_bash_infra` y exporta `ANDROID_HOME` (o `source ~/android-sdk/env.sh`).
|
||||
- **`ADB` se resuelve una sola vez al sourcing.** Cambiar el SDK despues requiere re-sourcear.
|
||||
- **Sourcéala con bash, no zsh.** Los consumidores usan `${BASH_SOURCE[0]}` para localizar este archivo; ejecutarlos con `bash <file>` (no `zsh`/`source` desde zsh) resuelve el path correctamente.
|
||||
|
||||
## Notas
|
||||
## Capability growth log
|
||||
|
||||
- El script es **source-able**: define funciones en el shell actual, no crea subshell.
|
||||
- `ADB` se resuelve una sola vez al sourcing. Si el binario no existe en disco, la carga falla con mensaje en stderr y `return 1` / `exit 1`.
|
||||
- `adb_wait_boot` hace polling cada 3 segundos. Ajustar `interval` si el emulador es especialmente lento.
|
||||
- En WSL2 `wslpath` siempre está disponible; el fallback existe para entornos Linux puros que accidentalmente sourceen el archivo.
|
||||
- Si el emulador requiere `-s <serial>`, pasar el flag directamente a `adb_run`: `adb_run -s emulator-5554 shell ...`.
|
||||
---
|
||||
- v1.1.0 (2026-06-03) — Linux-first: la resolucion de adb ahora prioriza el adb nativo del SDK (`$ANDROID_HOME/platform-tools/adb`) y del PATH; el adb.exe de Windows queda como fallback legacy solo bajo WSL2. Se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. Todo el toolbelt android (~20 funciones) pasa a funcionar en Linux nativo sin preexportar `ADB`.
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# adb_wsl — Wrapper sourceable para usar adb.exe Windows desde WSL2.
|
||||
# adb_wsl — Wrapper sourceable para resolver e invocar adb.
|
||||
# Linux-first: usa el adb nativo del Android SDK o del PATH. Conserva un
|
||||
# fallback a adb.exe SOLO cuando se detecta WSL2 (legacy). El nombre del
|
||||
# archivo se mantiene por compatibilidad con sus consumidores del registry.
|
||||
# Uso: source bash/functions/infra/adb_wsl.sh
|
||||
# Smoke test: bash bash/functions/infra/adb_wsl.sh --self-test
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver ADB
|
||||
# Resolver ADB (Linux-first; fallback WSL legacy)
|
||||
# ---------------------------------------------------------------------------
|
||||
# El caller puede fijar ADB antes de sourcing para apuntar a otro binario.
|
||||
# Prioridad de resolucion:
|
||||
# 1. $ADB preexportada por el caller (override explicito).
|
||||
# 2. adb nativo del Android SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT).
|
||||
# 3. adb del PATH.
|
||||
# 4. (legacy) adb.exe de Windows, solo si corremos dentro de WSL2.
|
||||
if [[ -z "${ADB:-}" ]]; then
|
||||
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||
ADB="${_sdk_root}/platform-tools/adb.exe"
|
||||
unset _sdk_root
|
||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
|
||||
ADB="$_sdk/platform-tools/adb"
|
||||
elif command -v adb &>/dev/null; then
|
||||
ADB="$(command -v adb)"
|
||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
||||
_sdk_win="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}"
|
||||
ADB="${_sdk_win}/platform-tools/adb.exe"
|
||||
unset _sdk_win
|
||||
fi
|
||||
unset _sdk
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ADB" ]]; then
|
||||
echo "adb_wsl: ADB no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN= antes de sourcear." >&2
|
||||
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
|
||||
echo "adb_wsl: adb no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra), exporta ANDROID_HOME, o fija ADB= antes de sourcear." >&2
|
||||
# Solo abortamos si el script se ejecuta directamente; si se sourcea,
|
||||
# permitimos continuar para que el caller maneje el error.
|
||||
return 1 2>/dev/null || exit 1
|
||||
@@ -22,8 +37,8 @@ fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# adb_run "<args...>"
|
||||
# Ejecuta el ADB Windows con los argumentos dados.
|
||||
# Retorna el exit code de adb.exe.
|
||||
# Ejecuta adb (el binario resuelto en $ADB) con los argumentos dados.
|
||||
# Retorna el exit code de adb.
|
||||
# ---------------------------------------------------------------------------
|
||||
adb_run() {
|
||||
"$ADB" "$@"
|
||||
|
||||
@@ -3,11 +3,11 @@ name: android_emulator_list
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "android_emulator_list([--json])"
|
||||
description: "Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2."
|
||||
tags: [android, emulator, wsl]
|
||||
description: "Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2."
|
||||
tags: [android, emulator, linux, avd, wsl]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -17,35 +17,41 @@ imports: []
|
||||
params:
|
||||
- name: "--json"
|
||||
desc: "Optional flag, outputs JSON array instead of newline-separated names"
|
||||
output: "Lista de AVDs disponibles en el SDK Windows. Una por linea, o JSON array con --json."
|
||||
output: "Lista de AVDs disponibles en el SDK. Una por linea, o JSON array con --json."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/android_emulator_list.sh"
|
||||
notes: "Lee env var EMULATOR o ANDROID_SDK_WIN. Default Windows path: /mnt/c/Users/lucas/AppData/Local/Android/Sdk/emulator/emulator.exe. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe o no es ejecutable."
|
||||
notes: "Resuelve el binario emulator Linux-first ($ANDROID_HOME/emulator/emulator -> emulator del PATH -> emulator.exe si WSL2). Override con EMULATOR=. Exit 0 si lista (incluso vacia). Exit 1 solo si el binario no existe."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source ~/android-sdk/env.sh # exporta ANDROID_HOME
|
||||
|
||||
# Listar AVDs (una por linea)
|
||||
android_emulator_list
|
||||
# Pixel_API34
|
||||
|
||||
# Listar AVDs en formato JSON
|
||||
android_emulator_list --json
|
||||
# ["Pixel_7_API_34","Pixel_4_API_30"]
|
||||
# ["Pixel_API34"]
|
||||
|
||||
# Sobreescribir ruta del emulador
|
||||
EMULATOR="/custom/path/emulator.exe" android_emulator_list
|
||||
|
||||
# Sobreescribir SDK base
|
||||
ANDROID_SDK_WIN="/mnt/d/Android/Sdk" android_emulator_list
|
||||
EMULATOR="/opt/android/emulator/emulator" android_emulator_list
|
||||
```
|
||||
|
||||
## Notas
|
||||
## Cuando usarla
|
||||
|
||||
El script es ejecutable directamente (`chmod +x`) o invocable con `bash android_emulator_list.sh`.
|
||||
Antes de arrancar un emulador, para validar que el AVD existe (lo hace `deploy_capacitor_to_emulator` y `run_kotlin_app_tests` internamente). Útil también para listar qué AVDs hay creados en la máquina.
|
||||
|
||||
`emulator.exe -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra ademas lineas vacias para producir una lista limpia.
|
||||
## Gotchas
|
||||
|
||||
La variable `EMULATOR` tiene prioridad sobre `ANDROID_SDK_WIN`. Si ninguna esta definida se usa el path Windows por defecto de Lucas.
|
||||
- **Linux-first.** El default ya no es Windows. Resuelve `$ANDROID_HOME/emulator/emulator`, luego `emulator` del PATH, y solo bajo WSL2 cae a `emulator.exe`.
|
||||
- `emulator -list-avds` imprime warnings a stderr que se descartan con `2>/dev/null`. La captura con `mapfile` filtra líneas vacías.
|
||||
- Override del binario con `EMULATOR=`; override del SDK con `ANDROID_HOME=`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-03) — Linux-first: resuelve el emulator nativo del SDK (`$ANDROID_HOME`) y del PATH antes que `emulator.exe`; se elimina el default hardcodeado `/mnt/c/Users/lucas/...`.
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# android_emulator_list — Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2.
|
||||
# android_emulator_list — Lista los AVDs disponibles. Linux-first: usa el
|
||||
# emulator nativo del Android SDK; fallback a emulator.exe solo bajo WSL2.
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve emulator binary
|
||||
EMULATOR="${EMULATOR:-${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}/emulator/emulator.exe}"
|
||||
# Resolve emulator binary (Linux-first; WSL fallback)
|
||||
if [[ -z "${EMULATOR:-}" ]]; then
|
||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
|
||||
EMULATOR="$_sdk/emulator/emulator"
|
||||
elif command -v emulator &>/dev/null; then
|
||||
EMULATOR="$(command -v emulator)"
|
||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
||||
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
|
||||
fi
|
||||
unset _sdk
|
||||
fi
|
||||
|
||||
if [[ ! -x "$EMULATOR" ]]; then
|
||||
echo "error: emulator binary not found or not executable: $EMULATOR" >&2
|
||||
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
|
||||
echo "error: emulator no encontrado. Instala el SDK (fn run install_android_sdk_bash_infra) + el paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ name: android_emulator_start
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "android_emulator_start(avd_name: string, timeout_s: int) -> string"
|
||||
description: "Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro."
|
||||
tags: [android, emulator, wsl]
|
||||
description: "Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro."
|
||||
tags: [android, emulator, linux, avd, wsl]
|
||||
params:
|
||||
- name: avd_name
|
||||
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator.exe -list-avds`)"
|
||||
desc: "Nombre del AVD a arrancar (visible con android_emulator_list o `emulator -list-avds`)"
|
||||
- name: timeout_s
|
||||
desc: "Timeout total en segundos para esperar el boot completo. Opcional, default 180"
|
||||
output: "Serial del device emulado (ej. emulator-5554) en stdout. Exit 0 = boot completo, exit 1 = timeout o emulador murio."
|
||||
@@ -29,21 +29,31 @@ file_path: "bash/functions/infra/android_emulator_start.sh"
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source ~/android-sdk/env.sh # exporta ANDROID_HOME -> resuelve emulator/adb nativos
|
||||
source bash/functions/infra/android_emulator_start.sh
|
||||
|
||||
# Arrancar AVD con timeout por defecto (180s)
|
||||
serial=$(android_emulator_start "Pixel_6_API_34")
|
||||
serial=$(android_emulator_start "Pixel_API34")
|
||||
echo "Emulador listo: $serial" # emulator-5554
|
||||
|
||||
# Con timeout personalizado
|
||||
serial=$(android_emulator_start "Pixel_6_API_34" 300)
|
||||
serial=$(android_emulator_start "Pixel_API34" 300)
|
||||
```
|
||||
|
||||
## Notas
|
||||
Para ver la ventana del emulador en un escritorio Linux, exporta `DISPLAY` (y `XAUTHORITY`) antes de invocar.
|
||||
|
||||
- Sourcea `adb_wsl.sh` del mismo directorio si existe (provee `ADB`, `adb_run`, `adb_wait_boot`). Si no, usa implementacion inline.
|
||||
- Resuelve `EMULATOR` y `ADB` desde `ANDROID_SDK_WIN` (default `/mnt/c/Users/lucas/AppData/Local/Android/Sdk`) o desde las variables de entorno `EMULATOR=` / `ADB=` si ya están fijadas.
|
||||
- Idempotente: si `adb devices` ya muestra un `emulator-*`, imprime "already running" + el serial y sale con exit 0 sin lanzar un segundo proceso.
|
||||
- Log del emulador en `/tmp/emulator_<avd>.log`. PID en `/tmp/emulator_<avd>.pid`.
|
||||
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda mitad para esperar `sys.boot_completed=1`.
|
||||
- Diseñado para WSL2 con Android SDK instalado en Windows. En Linux nativo basta cambiar las rutas de los binarios via `EMULATOR=` y `ADB=`.
|
||||
## Cuando usarla
|
||||
|
||||
Cuando un script necesita un emulador booteado antes de instalar un APK o correr tests instrumentados (`gradle_instrumented_test`, `run_kotlin_app_tests`). Es idempotente, así que se puede llamar al principio de cualquier pipeline sin comprobar antes si ya hay uno arriba.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Linux-first.** Resuelve `EMULATOR`/`ADB` desde `$ANDROID_HOME/{emulator/emulator, platform-tools/adb}` o del PATH; `emulator.exe`/`adb.exe` solo como fallback bajo WSL2. Override manual con `EMULATOR=`/`ADB=`.
|
||||
- **Necesita `DISPLAY` para ventana.** Sin un servidor X accesible el emulador puede fallar al abrir ventana. Para headless/CI añade `-no-window` (editar la función o lanzar el emulador aparte).
|
||||
- **Aceleración KVM.** Requiere acceso a `/dev/kvm` (grupo `kvm` o ACL). Sin ella el boot es lentísimo o falla.
|
||||
- Log del emulador en `/tmp/emulator_<avd>.log`, PID en `/tmp/emulator_<avd>.pid`.
|
||||
- El timeout total se reparte: primera mitad para `adb wait-for-device`, segunda para `sys.boot_completed=1`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-03) — Linux-first: resuelve emulator/adb nativos del SDK (`$ANDROID_HOME`) antes que los `.exe` de Windows (ahora solo fallback WSL2); se elimina el default hardcodeado `/mnt/c/Users/lucas/...`. fix: `timeout <n> adb_run wait-for-device` fallaba siempre porque `timeout` no puede ejecutar la función shell `adb_run`; ahora invoca el binario `"$ADB"` directamente.
|
||||
|
||||
@@ -11,11 +11,17 @@ if [[ -f "$_ADB_WSL_SH" ]]; then
|
||||
# shellcheck source=adb_wsl.sh
|
||||
source "$_ADB_WSL_SH"
|
||||
else
|
||||
# Fallback inline: resolver ADB
|
||||
# Fallback inline: resolver ADB (Linux-first; WSL fallback)
|
||||
if [[ -z "${ADB:-}" ]]; then
|
||||
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||
ADB="${_sdk_root}/platform-tools/adb.exe"
|
||||
unset _sdk_root
|
||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -n "$_sdk" && -x "$_sdk/platform-tools/adb" ]]; then
|
||||
ADB="$_sdk/platform-tools/adb"
|
||||
elif command -v adb &>/dev/null; then
|
||||
ADB="$(command -v adb)"
|
||||
else
|
||||
ADB="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/platform-tools/adb.exe"
|
||||
fi
|
||||
unset _sdk
|
||||
fi
|
||||
adb_run() { "$ADB" "$@"; }
|
||||
adb_wait_boot() {
|
||||
@@ -33,12 +39,18 @@ else
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver EMULATOR
|
||||
# Resolver EMULATOR (Linux-first; WSL fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -z "${EMULATOR:-}" ]]; then
|
||||
_sdk_root="${ANDROID_SDK_WIN:-/mnt/c/Users/lucas/AppData/Local/Android/Sdk}"
|
||||
EMULATOR="${_sdk_root}/emulator/emulator.exe"
|
||||
unset _sdk_root
|
||||
_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
if [[ -n "$_sdk" && -x "$_sdk/emulator/emulator" ]]; then
|
||||
EMULATOR="$_sdk/emulator/emulator"
|
||||
elif command -v emulator &>/dev/null; then
|
||||
EMULATOR="$(command -v emulator)"
|
||||
elif grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null; then
|
||||
EMULATOR="${ANDROID_SDK_WIN:-/mnt/c/Users/$USER/AppData/Local/Android/Sdk}/emulator/emulator.exe"
|
||||
fi
|
||||
unset _sdk
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,12 +61,12 @@ android_emulator_start() {
|
||||
local timeout_s="${2:-180}"
|
||||
|
||||
# Validaciones de entorno
|
||||
if [[ ! -f "$EMULATOR" ]]; then
|
||||
echo "android_emulator_start: emulator.exe no encontrado en '$EMULATOR'. Fija EMULATOR= o ANDROID_SDK_WIN=." >&2
|
||||
if [[ -z "${EMULATOR:-}" ]] || ! command -v "$EMULATOR" &>/dev/null; then
|
||||
echo "android_emulator_start: emulator no encontrado. Instala el SDK + paquete 'emulator', exporta ANDROID_HOME, o fija EMULATOR=." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "$ADB" ]]; then
|
||||
echo "android_emulator_start: adb.exe no encontrado en '$ADB'. Fija ADB= o ANDROID_SDK_WIN=." >&2
|
||||
if [[ -z "${ADB:-}" ]] || ! command -v "$ADB" &>/dev/null; then
|
||||
echo "android_emulator_start: adb no encontrado. Instala platform-tools, exporta ANDROID_HOME, o fija ADB=." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -74,9 +86,12 @@ android_emulator_start() {
|
||||
local emu_pid=$!
|
||||
echo "$emu_pid" > "$pid_file"
|
||||
|
||||
# Esperar a que el dispositivo aparezca en adb
|
||||
# Esperar a que el dispositivo aparezca en adb.
|
||||
# Usamos el binario "$ADB" directamente (no la funcion adb_run): `timeout`
|
||||
# ejecuta un comando externo y no puede ver funciones del shell, asi que
|
||||
# `timeout ... adb_run` fallaba siempre con "command not found".
|
||||
local wait_timeout=$(( timeout_s / 2 ))
|
||||
if ! timeout "$wait_timeout" adb_run wait-for-device 2>/dev/null; then
|
||||
if ! timeout "$wait_timeout" "$ADB" wait-for-device 2>/dev/null; then
|
||||
echo "android_emulator_start: timeout esperando que el dispositivo aparezca en adb (${wait_timeout}s)." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: audit_doctor_snapshot
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "audit_doctor_snapshot(doctor_subcommand: string, snapshot_base_dir: string) -> void"
|
||||
description: "Ejecuta un subcomando de fn doctor --json, guarda un snapshot JSON fechado en <base>/<sub>/<stamp>.json, lo compara con la corrida anterior (latest.json) y emite a stdout un resumen legible: count actual, count previo, IDs nuevos y resueltos. Pieza de observabilidad Nivel 1 para DAGs de auditoría periódica."
|
||||
tags: [audit, registry, infra, doctor, snapshot, diff, dag]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: doctor_subcommand
|
||||
desc: "Subcomando de fn doctor a ejecutar (unused, capabilities, artefacts, copied-code, uses-functions, cpp-apps, services, sync, etc.)."
|
||||
- name: snapshot_base_dir
|
||||
desc: "Directorio base donde se crea la carpeta <base>/<subcommand>/ con los snapshots fechados y latest.json."
|
||||
output: "Resumen a stdout: '[audit:<sub>] count=N prev=M +X new -Y resolved'. Si hay IDs nuevos/resueltos, líneas adicionales NEW:/RESOLVED: con hasta 8 IDs. Snapshots JSON en disco."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/audit_doctor_snapshot.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Primera corrida — establece baseline
|
||||
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
||||
FN_BIN=/home/enmanuel/fn_registry/fn \
|
||||
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
||||
unused \
|
||||
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||
# => [audit:unused] count=12 prev=- baseline (sin corrida previa)
|
||||
|
||||
# Segunda corrida — compara contra latest.json
|
||||
FN_REGISTRY_ROOT=/home/enmanuel/fn_registry \
|
||||
FN_BIN=/home/enmanuel/fn_registry/fn \
|
||||
bash bash/functions/infra/audit_doctor_snapshot.sh \
|
||||
unused \
|
||||
/home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||
# => [audit:unused] count=12 prev=12 +0 new -0 resolved
|
||||
|
||||
# Con otro subcomando (directorio independiente automático)
|
||||
audit_doctor_snapshot artefacts /tmp/audits/weekly
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Úsala en un DAG/cron que ejecuta `fn doctor` periódicamente y quieres **persistir el resultado y ver qué cambió desde la última corrida**: funciones huérfanas que aparecieron, artefactos rotos nuevos, capabilities sin doc, etc. Es la pieza "snapshot + diff" del Nivel 1 de observabilidad de auditorías — el DAG llama esta función en vez de descartar el output de `fn doctor`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Depende de `FN_BIN` o `FN_REGISTRY_ROOT`** en el entorno. Si ninguno está seteado, asume `$HOME/fn_registry/fn`. En DAGs, asegúrate de exportar `FN_REGISTRY_ROOT` antes de invocar.
|
||||
- **`latest.json` se sobreescribe cada corrida** — es el snapshot de referencia para el diff siguiente. No es un historial acumulado; el historial está en los archivos fechados `<stamp>.json`.
|
||||
- **Si cambias de subcomando, el subdirectorio es distinto** (`<base>/unused/` vs `<base>/artefacts/`), así que no hay contaminación entre subcomandos aunque compartan el mismo `base_dir`.
|
||||
- **Si `fn doctor <sub>` falla (rc != 0)**, la función propaga ese exit code. Esto es intencional: doctor roto = problema real que el DAG debe reportar. Los hallazgos normales (funciones huérfanas, artefactos con drift) tienen rc=0 en `fn doctor`.
|
||||
- **jq es dependencia requerida**. Está disponible en el ecosistema del registry pero si el entorno no lo tiene, los conteos y diffs de IDs caen a `?`/textual respectivamente.
|
||||
- **Retención automática**: snapshots fechados con más de 30 días se borran con `find -mtime +30`. `latest.json` nunca se borra.
|
||||
- **Estructura del JSON de `fn doctor`**: el diff de IDs busca campos `.ID` o `.id` en los elementos. Si el subcomando produce una estructura distinta (objeto anidado sin esos campos), el diff cae a comparación textual, que sigue siendo útil.
|
||||
|
||||
## Notas
|
||||
|
||||
Diseñada para ser invocada desde steps del dag_engine (`daily-registry-audit`, `weekly-deep-scan`) como reemplazo del descarte silencioso del output de `fn doctor --json`. La salida stdout es legible por humanos y parseable por el orquestador del DAG para decidir si crear proposals.
|
||||
|
||||
Binario `fn` resuelto en orden: `$FN_BIN` → `${FN_REGISTRY_ROOT}/fn` → `$HOME/fn_registry/fn`.
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# audit_doctor_snapshot — ejecuta un subcomando de fn doctor, guarda snapshot JSON
|
||||
# fechado, compara con la corrida anterior y emite resumen legible de cambios.
|
||||
#
|
||||
# Uso: audit_doctor_snapshot <doctor_subcommand> <snapshot_base_dir>
|
||||
#
|
||||
# Ejemplo:
|
||||
# audit_doctor_snapshot unused /home/enmanuel/fn_registry/apps/dag_engine/local_files/audits/daily
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
audit_doctor_snapshot() {
|
||||
local sub="${1:-}"
|
||||
local base="${2:-}"
|
||||
|
||||
# --- validacion de argumentos ---
|
||||
if [[ -z "$sub" || -z "$base" ]]; then
|
||||
echo "usage: audit_doctor_snapshot <subcommand> <base_dir>" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# --- resolver binario fn ---
|
||||
local fn_bin="${FN_BIN:-${FN_REGISTRY_ROOT:-$HOME/fn_registry}/fn}"
|
||||
if [[ ! -x "$fn_bin" ]]; then
|
||||
echo "audit_doctor_snapshot: binario fn no encontrado o no ejecutable: $fn_bin" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# --- preparar directorio ---
|
||||
local dir="$base/$sub"
|
||||
mkdir -p "$dir"
|
||||
|
||||
# --- ejecutar fn doctor ---
|
||||
local stderr_tmp
|
||||
stderr_tmp="$(mktemp /tmp/audit_doctor_snapshot_stderr.XXXXXX)"
|
||||
local json rc
|
||||
json="$("$fn_bin" doctor "$sub" --json 2>"$stderr_tmp")" || rc=$?
|
||||
rc="${rc:-0}"
|
||||
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
cat "$stderr_tmp" >&2
|
||||
echo "audit_doctor_snapshot: 'fn doctor $sub' fallo (rc=$rc)" >&2
|
||||
rm -f "$stderr_tmp"
|
||||
return "$rc"
|
||||
fi
|
||||
rm -f "$stderr_tmp"
|
||||
|
||||
# --- normalizar con jq (diff estable) ---
|
||||
local stamp
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
local curr="$dir/${stamp}.json"
|
||||
local nojson=0
|
||||
|
||||
if ! echo "$json" | jq -S . > "$curr" 2>/dev/null; then
|
||||
# salida no es JSON valido -> guardar crudo
|
||||
printf '%s' "$json" > "$curr"
|
||||
nojson=1
|
||||
fi
|
||||
|
||||
# --- snapshot anterior ---
|
||||
local prev="$dir/latest.json"
|
||||
|
||||
# --- contar hallazgos actuales ---
|
||||
local count="?"
|
||||
if [[ "$nojson" -eq 0 ]]; then
|
||||
if jq -e 'type == "array"' "$curr" >/dev/null 2>&1; then
|
||||
count="$(jq 'length' "$curr")"
|
||||
elif jq -e 'type == "object"' "$curr" >/dev/null 2>&1; then
|
||||
count="$(jq 'keys | length' "$curr")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- contar hallazgos previos ---
|
||||
local prevcount="-"
|
||||
if [[ -f "$prev" ]]; then
|
||||
if jq -e 'type == "array"' "$prev" >/dev/null 2>&1; then
|
||||
prevcount="$(jq 'length' "$prev")"
|
||||
elif jq -e 'type == "object"' "$prev" >/dev/null 2>&1; then
|
||||
prevcount="$(jq 'keys | length' "$prev")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- diff de identidad ---
|
||||
local new_count=0
|
||||
local resolved_count=0
|
||||
local new_ids=()
|
||||
local resolved_ids=()
|
||||
local diff_label=""
|
||||
|
||||
if [[ ! -f "$prev" ]]; then
|
||||
diff_label="baseline (sin corrida previa)"
|
||||
elif [[ "$nojson" -eq 1 ]]; then
|
||||
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
||||
diff_label="changed (textual)"
|
||||
else
|
||||
diff_label="+0 new -0 resolved"
|
||||
fi
|
||||
else
|
||||
# extraer IDs estables: .ID o .id
|
||||
local curr_ids prev_ids
|
||||
curr_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$curr" 2>/dev/null | sort -u)"
|
||||
prev_ids="$(jq -r 'if type=="array" then .[].ID // .[].id // empty else to_entries[].value.ID // to_entries[].value.id // empty end' "$prev" 2>/dev/null | sort -u)"
|
||||
|
||||
if [[ -n "$curr_ids" || -n "$prev_ids" ]]; then
|
||||
# NEW: en curr pero no en prev
|
||||
local new_raw resolved_raw
|
||||
new_raw="$(comm -23 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
||||
resolved_raw="$(comm -13 <(echo "$curr_ids") <(echo "$prev_ids") 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$new_raw" ]]; then
|
||||
mapfile -t new_ids <<< "$new_raw"
|
||||
fi
|
||||
if [[ -n "$resolved_raw" ]]; then
|
||||
mapfile -t resolved_ids <<< "$resolved_raw"
|
||||
fi
|
||||
|
||||
new_count="${#new_ids[@]}"
|
||||
resolved_count="${#resolved_ids[@]}"
|
||||
diff_label="+${new_count} new -${resolved_count} resolved"
|
||||
else
|
||||
# sin campo .ID/.id — fallback textual
|
||||
if ! diff -q "$prev" "$curr" >/dev/null 2>&1; then
|
||||
diff_label="changed (textual)"
|
||||
else
|
||||
diff_label="+0 new -0 resolved"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- resumen a stdout ---
|
||||
echo "[audit:$sub] count=$count prev=$prevcount $diff_label"
|
||||
|
||||
# listar nuevos (max 8)
|
||||
if [[ "${#new_ids[@]}" -gt 0 ]]; then
|
||||
local listed=("${new_ids[@]:0:8}")
|
||||
local extra=$(( ${#new_ids[@]} - 8 ))
|
||||
local line
|
||||
line="$(IFS=', '; echo "${listed[*]}")"
|
||||
if [[ "$extra" -gt 0 ]]; then
|
||||
line="${line} (+${extra} más)"
|
||||
fi
|
||||
echo " NEW: $line"
|
||||
fi
|
||||
|
||||
# listar resueltos (max 8)
|
||||
if [[ "${#resolved_ids[@]}" -gt 0 ]]; then
|
||||
local listed_r=("${resolved_ids[@]:0:8}")
|
||||
local extra_r=$(( ${#resolved_ids[@]} - 8 ))
|
||||
local line_r
|
||||
line_r="$(IFS=', '; echo "${listed_r[*]}")"
|
||||
if [[ "$extra_r" -gt 0 ]]; then
|
||||
line_r="${line_r} (+${extra_r} más)"
|
||||
fi
|
||||
echo " RESOLVED: $line_r"
|
||||
fi
|
||||
|
||||
# --- actualizar puntero latest ---
|
||||
cp "$curr" "$prev"
|
||||
|
||||
# --- retención: borrar snapshots fechados > 30 días ---
|
||||
find "$dir" -maxdepth 1 -name '*.json' ! -name 'latest.json' -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecución directa
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
audit_doctor_snapshot "$@"
|
||||
fi
|
||||
@@ -32,7 +32,7 @@ discover_git_repos() {
|
||||
-not -path "*/node_modules/*" \
|
||||
-not -path "*/.venv/*" \
|
||||
-not -path "*/cpp/vendor/*" \
|
||||
-not -path "*/cpp/build/*" \
|
||||
-not -path "*/cpp/build*/*" \
|
||||
-not -path "*/sources/*" \
|
||||
-not -path "*/temp/*" \
|
||||
-not -path "*/subrepos/*" \
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: ensure_project_gitignore
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "ensure_project_gitignore(project_dir: string) -> void"
|
||||
description: "Garantiza de forma idempotente que el .gitignore de un directorio de project contiene las lineas canonicas que excluyen del repo del project el contenido de sus sub-repos hijos (apps y analyses son repos Gitea independientes) y sus vaults (datos fuera de git). Evita el doble-tracking al hacer push del project."
|
||||
tags: [git, gitignore, projects, infra]
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "Ruta al directorio del project (p. ej. projects/aurgi). Debe existir; si no, error a stderr y return 1. El .gitignore se escribe/actualiza en <project_dir>/.gitignore."
|
||||
output: "Sin salida en stdout. A stderr informa de la accion realizada: 'created' si creo el .gitignore, 'updated: anadidas N lineas' si anadio lineas faltantes, u 'ok: ya completo' si nada cambiaba. Codigo de salida 0 en exito, 1 si project_dir falta o no existe."
|
||||
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/ensure_project_gitignore.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/ensure_project_gitignore.sh
|
||||
|
||||
# Asegura que projects/aurgi/.gitignore excluye el contenido de sus hijos.
|
||||
ensure_project_gitignore projects/aurgi
|
||||
# stderr: ensure_project_gitignore: created projects/aurgi/.gitignore
|
||||
# (o: updated: anadidas 2 lineas / ok: ya completo)
|
||||
```
|
||||
|
||||
Las lineas canonicas que la funcion garantiza son:
|
||||
|
||||
```
|
||||
apps/*/
|
||||
analysis/*/
|
||||
vaults/*
|
||||
!vaults/.gitkeep
|
||||
!vaults/vault.yaml
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llamala justo despues de crear un project nuevo (`mkdir -p projects/<nombre>/{apps,analysis,vaults}`) y antes de inicializar su repo Gitea con `ensure_repo_synced`, para que el repo del project nunca trackee el contenido de sus sub-repos hijos. Tambien al adoptar un project existente que aun no tiene estas exclusiones, o como paso de saneamiento cuando `git status` del project muestra contenido de `apps/`/`analysis/` que deberia estar ignorado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- La funcion modifica el filesystem (escribe en `<project_dir>/.gitignore`): es impura. No commitea ni hace push — solo deja el `.gitignore` correcto.
|
||||
- La comparacion para no duplicar es linea-exacta (`grep -Fxq`). Una linea equivalente pero con espacios extra, comentario adjunto o glob distinto (p. ej. `apps/*` sin la barra final) NO se considera presente y la canonica se anade igualmente; podrian quedar ambas formas. Mantener el `.gitignore` con las lineas canonicas tal cual evita ruido.
|
||||
- Si el `.gitignore` existente no termina en salto de linea, la funcion anade uno antes de apendar para no pegar la primera linea nueva al final de la ultima existente.
|
||||
- Solo gestiona las exclusiones de sub-repos hijos y vaults del nivel-project; no toca otras reglas que el `.gitignore` ya contenga ni las reordena.
|
||||
- Si una linea canonica ya existia con su forma exacta, no se vuelve a anadir (idempotente): re-ejecutar es seguro.
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# ensure_project_gitignore — Garantiza de forma idempotente que el .gitignore de
|
||||
# un directorio de project (projects/<nombre>/) contiene las lineas canonicas que
|
||||
# excluyen del repo del project el contenido de sus sub-repos hijos (apps y
|
||||
# analyses son repos Gitea independientes) y sus vaults (datos fuera de git).
|
||||
#
|
||||
# Esto evita que al hacer push del project se trackee por error el contenido de
|
||||
# los hijos (doble-tracking). Ver .claude/rules/apps_subrepo.md y
|
||||
# .claude/rules/projects.md.
|
||||
#
|
||||
# Uso:
|
||||
# ensure_project_gitignore <project_dir>
|
||||
#
|
||||
# Salida:
|
||||
# stdout vacio. A stderr informa de la accion realizada (created / updated / ok).
|
||||
|
||||
ensure_project_gitignore() {
|
||||
local project_dir="$1"
|
||||
|
||||
if [[ -z "$project_dir" ]]; then
|
||||
echo "ensure_project_gitignore: se requiere project_dir" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$project_dir" ]]; then
|
||||
echo "ensure_project_gitignore: directorio '$project_dir' no existe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local gitignore="$project_dir/.gitignore"
|
||||
|
||||
# Lineas canonicas que deben estar presentes (orden de referencia).
|
||||
local -a canonical=(
|
||||
"apps/*/"
|
||||
"analysis/*/"
|
||||
"vaults/*"
|
||||
"!vaults/.gitkeep"
|
||||
"!vaults/vault.yaml"
|
||||
)
|
||||
|
||||
# Caso 1: el .gitignore no existe — crearlo con el contenido canonico.
|
||||
if [[ ! -f "$gitignore" ]]; then
|
||||
printf '%s\n' "${canonical[@]}" > "$gitignore"
|
||||
echo "ensure_project_gitignore: created $gitignore" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Caso 2: existe — anadir solo las lineas que falten (comparacion linea-exacta),
|
||||
# preservando el contenido y el orden existentes.
|
||||
# Si el archivo no termina en newline, anadir uno antes de apendar para no
|
||||
# pegar la primera linea nueva al final de la ultima existente.
|
||||
if [[ -s "$gitignore" && -n "$(tail -c 1 "$gitignore")" ]]; then
|
||||
printf '\n' >> "$gitignore"
|
||||
fi
|
||||
|
||||
local line added=0
|
||||
for line in "${canonical[@]}"; do
|
||||
# grep -F -x: match literal de linea completa, sin interpretar metacaracteres.
|
||||
if ! grep -Fxq -- "$line" "$gitignore"; then
|
||||
printf '%s\n' "$line" >> "$gitignore"
|
||||
added=$((added + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $added -gt 0 ]]; then
|
||||
echo "ensure_project_gitignore: updated: anadidas $added lineas a $gitignore" >&2
|
||||
else
|
||||
echo "ensure_project_gitignore: ok: ya completo $gitignore" >&2
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Si se invoca como script (no source), ejecutar la funcion.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
ensure_project_gitignore "$@"
|
||||
fi
|
||||
@@ -3,7 +3,7 @@ name: install_android_sdk
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.0.1"
|
||||
purity: impure
|
||||
signature: "install_android_sdk() -> void"
|
||||
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
||||
@@ -50,6 +50,17 @@ ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
|
||||
source ~/android-sdk/env.sh
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un Android SDK funcional en una maquina Linux sin permisos de root: CI, contenedores, o un PC de desarrollo donde quieras un SDK aislado en `$HOME`. Instala la base minima para compilar (cmdline-tools + JDK 17 + platform-tools + API 34 + build-tools). Hazle `source` para tener `sdkmanager`/`avdmanager`/`adb` en el PATH antes de invocar `gradle_run`, `gradle_assemble_debug` o `capacitor_build_apk`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No instala `emulator` ni system images.** Solo la base de compilacion. Para correr un AVD: tras hacer `source env.sh`, instala `emulator` y una imagen (`sdkmanager "emulator" "system-images;android-34;google_apis;x86_64"`) y crea el AVD con `avdmanager create avd`.
|
||||
- **Aceleracion KVM:** el emulador necesita acceso a `/dev/kvm`. Verifica con `[ -w /dev/kvm ]`; si no, anade tu usuario al grupo `kvm` (`sudo usermod -aG kvm $USER` + re-login) o concede ACL.
|
||||
- **URL de cmdline-tools clavada** a la build 11076708 (2024). Si Google la retira, actualizar `tools_url` en el `.sh`.
|
||||
- **Idempotente:** re-ejecutar no reinstala; detecta `sdkmanager` existente y sale en 0.
|
||||
|
||||
## Notas
|
||||
|
||||
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
||||
@@ -61,3 +72,7 @@ La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools co
|
||||
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
||||
|
||||
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.0.1 (2026-06-03) — fix: `yes | sdkmanager --licenses` daba falso negativo bajo `pipefail` (SIGPIPE de `yes`, exit 141) abortando una instalacion exitosa; ahora se desactiva `pipefail` solo en ese pipe. fix: el trap `EXIT` referenciaba `$tmp_dir` (variable `local`) fuera del scope de la funcion → "unbound variable" con `set -u`; ahora es global con expansion defensiva.
|
||||
|
||||
@@ -5,11 +5,14 @@ set -euo pipefail
|
||||
|
||||
install_android_sdk() {
|
||||
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
||||
local tmp_dir
|
||||
# tmp_dir es global a proposito: el trap EXIT se dispara al terminar el
|
||||
# script (fuera del scope de la funcion), donde una variable `local` ya no
|
||||
# existiria y `set -u` la marcaria como unbound. La expansion defensiva
|
||||
# ${tmp_dir:-} evita el fallo aunque el trap corra antes de la asignacion.
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
# Limpia temporales al salir
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
trap 'rm -rf "${tmp_dir:-}"' EXIT
|
||||
|
||||
# 1. Verifica si ya está instalado
|
||||
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||
@@ -103,11 +106,18 @@ install_android_sdk() {
|
||||
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
||||
|
||||
# 4. Acepta licencias e instala paquetes necesarios
|
||||
# `yes` recibe SIGPIPE (exit 141) cuando sdkmanager termina de leer y cierra
|
||||
# el pipe; bajo `set -o pipefail` eso convierte un exito real en falso
|
||||
# negativo. Desactivamos pipefail solo aqui para que el exit del pipeline
|
||||
# refleje el de sdkmanager (ultimo comando), no el SIGPIPE de `yes`.
|
||||
echo "Aceptando licencias de Android SDK..."
|
||||
if ! yes | "$sdkmanager" --licenses; then
|
||||
set +o pipefail
|
||||
if ! yes | "$sdkmanager" --licenses >/dev/null 2>&1; then
|
||||
set -o pipefail
|
||||
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
||||
return 1
|
||||
fi
|
||||
set -o pipefail
|
||||
|
||||
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
||||
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: launch_claude_agent_kitty
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "launch_claude_agent_kitty(title: string, directory: string, prompt_file: string) -> string"
|
||||
description: "Lanza un Claude Code secundario interactivo y persistente en su propia terminal kitty, con un prompt autonomo inyectado desde un archivo y --dangerously-skip-permissions. Mecanica del modo orquestador: un Claude principal descompone una tarea y lanza N secundarios, cada uno en su kitty, que el humano ve y puede retomar. La ventana sobrevive al cierre de la terminal padre (setsid nohup ... disown) y deja una shell interactiva viva cuando el claude termina (exec zsh)."
|
||||
tags: [orchestration, agents, claude, kitty, agent, terminal, infra]
|
||||
params:
|
||||
- name: title
|
||||
desc: "Titulo de la ventana kitty. Ej: 'fn_registry · subtarea X'. Tambien se sanitiza (minusculas, no-alfanumerico -> '_') para derivar el slug del archivo de log."
|
||||
- name: directory
|
||||
desc: "Directorio de trabajo AISLADO donde arranca el claude secundario (worktree git, sub-repo, o dir cualquiera). Debe existir; si no -> error exit 2. Usar un dir aislado: dos claudes en el mismo working tree comparten HEAD y dispersan commits."
|
||||
- name: prompt_file
|
||||
desc: "Ruta a un archivo .md con el prompt autonomo a inyectar (ej. /tmp/orq_<slug>.md). Debe existir y ser legible; si no -> error exit 2."
|
||||
output: "Imprime en stdout el title, directory, prompt_file y la ruta del log (/tmp/orq_<slug>_kitty.log) donde se ve el arranque. Exit 0 = lanzamiento disparado; exit 2 = argumentos invalidos; exit 1 = kitty no instalado."
|
||||
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/launch_claude_agent_kitty.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
source bash/functions/infra/launch_claude_agent_kitty.sh
|
||||
|
||||
# El orquestador prepara un worktree aislado y un archivo de prompt...
|
||||
git worktree add /tmp/orq_docs_wt -b orq/docs
|
||||
cat > /tmp/orq_docs.md <<'PROMPT'
|
||||
Eres un agente secundario. Tu tarea: revisar y mejorar la documentacion del
|
||||
dominio infra del registry. Trabaja SOLO en este worktree. Reporta al terminar.
|
||||
PROMPT
|
||||
|
||||
# ...y lanza un claude secundario en su propia kitty:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
# -> abre una ventana kitty titulada "fn_registry · docs", arranca claude con
|
||||
# el prompt inyectado, y deja /tmp/orq_fn_registry_docs_kitty.log con el arranque.
|
||||
```
|
||||
|
||||
O directo via `fn run`:
|
||||
|
||||
```bash
|
||||
./fn run launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador quiere lanzar un Claude secundario **interactivo** en su propia terminal kitty para una sub-tarea que el humano quiere **ver y poder retomar**. A diferencia del `Agent` tool (sub-agente no interactivo, headless, cuyo output vuelve al padre y no deja terminal abierta), aqui cada secundario corre en una ventana visible que persiste: el humano observa el progreso en vivo y, cuando el claude termina, la shell sigue ahi para continuar manualmente o relanzar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **kitty debe estar instalado.** Si `command -v kitty` falla -> exit 1 con mensaje claro. No hay fallback a otra terminal.
|
||||
- **El `directory` debe ser AISLADO** (worktree git o sub-repo propio). Dos claudes apuntando al mismo working tree **comparten HEAD** y dispersan/cruzan los commits (memoria `multi-agent-git-race-same-repo`). El orquestador debe crear un worktree/clon por agente antes de llamar.
|
||||
- **`--dangerously-skip-permissions` corre sin pedir confirmacion** a ninguna accion (memoria `lanzar-agentes-skip-permissions`). Es a proposito para agentes autonomos desatendidos, pero es un riesgo asumido: el secundario puede tocar el sistema sin gates. No lanzar sobre directorios sensibles.
|
||||
- **El log de `/tmp/orq_<slug>_kitty.log` es donde se ve el arranque** (errores de kitty/claude al iniciar). El `<slug>` deriva del `title` sanitizado; titulos distintos que colapsen al mismo slug sobrescriben el mismo log.
|
||||
- **El PID reportado no es el de kitty.** Con `setsid` el `$!` es el del proceso setsid, no el de la ventana; por eso la funcion reporta el log en vez de un PID. Para encontrar la ventana despues: `pgrep -af kitty | grep <title>`.
|
||||
- **El prompt se inyecta con `"$(cat <prompt_file>)"` evaluado DENTRO de la kitty.** Si editas el `prompt_file` despues de lanzar pero antes de que la kitty arranque, el claude vera la version editada (se lee en el momento del arranque, no del lanzamiento).
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# launch_claude_agent_kitty — Lanza un Claude Code secundario interactivo y
|
||||
# persistente en su propia terminal kitty, con un prompt autonomo inyectado
|
||||
# desde un archivo. Es la mecanica de lanzamiento del "modo orquestador": un
|
||||
# Claude principal descompone una tarea y lanza N secundarios, cada uno en su
|
||||
# kitty, que el humano ve y puede retomar.
|
||||
#
|
||||
# Mecanismo:
|
||||
# - setsid nohup kitty ... & disown -> la ventana sobrevive al cierre de la
|
||||
# terminal padre (igual que reboot_all_claudes con setsid).
|
||||
# - zsh -ic 'claude ...; exec zsh' -> al terminar el claude queda una shell
|
||||
# interactiva viva para que el humano siga en esa terminal.
|
||||
# - --dangerously-skip-permissions -> agente autonomo desatendido (sin
|
||||
# confirmaciones). Riesgo asumido a proposito.
|
||||
# - El prompt se inyecta con "$(cat <prompt_file>)" para no expandir nada en
|
||||
# el shell del orquestador.
|
||||
# - Log de arranque en /tmp/orq_<slug>_kitty.log, donde <slug> deriva del
|
||||
# title (minusculas, no-alfanumerico -> '_').
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
launch_claude_agent_kitty() {
|
||||
# -----------------------------------------------------------------------
|
||||
# Ayuda / sin argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
cat <<'USAGE'
|
||||
Uso: launch_claude_agent_kitty <title> <directory> <prompt_file>
|
||||
|
||||
Lanza un Claude Code secundario interactivo y persistente en su propia
|
||||
terminal kitty, con el prompt del archivo <prompt_file> inyectado y
|
||||
--dangerously-skip-permissions (agente autonomo desatendido).
|
||||
|
||||
Argumentos (los 3 obligatorios):
|
||||
title Titulo de la ventana kitty. Ej: "fn_registry · subtarea X".
|
||||
directory Directorio de trabajo AISLADO donde arranca el claude
|
||||
secundario (worktree git, sub-repo, o dir cualquiera). Debe
|
||||
existir. Usa un dir aislado: dos claudes en el mismo working
|
||||
tree comparten HEAD y dispersan commits.
|
||||
prompt_file Ruta a un archivo .md con el prompt autonomo a inyectar.
|
||||
Debe existir y ser legible.
|
||||
|
||||
Ejemplo:
|
||||
launch_claude_agent_kitty "fn_registry · docs" /tmp/orq_docs_wt /tmp/orq_docs.md
|
||||
|
||||
El log de arranque va a /tmp/orq_<slug>_kitty.log (slug derivado del title).
|
||||
USAGE
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Validacion de argumentos.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "launch_claude_agent_kitty: se requieren 3 argumentos <title> <directory> <prompt_file> (recibidos: $#). Usa -h." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local title="$1"
|
||||
local directory="$2"
|
||||
local prompt_file="$3"
|
||||
|
||||
if [[ -z "$title" ]]; then
|
||||
echo "launch_claude_agent_kitty: <title> no puede estar vacio." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$directory" ]]; then
|
||||
echo "launch_claude_agent_kitty: el directorio de trabajo no existe: '$directory'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no existe: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ ! -r "$prompt_file" ]]; then
|
||||
echo "launch_claude_agent_kitty: el prompt_file no es legible: '$prompt_file'." >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Comprobar que kitty esta instalado.
|
||||
# -----------------------------------------------------------------------
|
||||
if ! command -v kitty >/dev/null 2>&1; then
|
||||
echo "launch_claude_agent_kitty: 'kitty' no esta instalado o no esta en el PATH." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Derivar el slug del title para el nombre del log.
|
||||
# minusculas, todo no-alfanumerico -> '_', colapsar/recortar '_'.
|
||||
# -----------------------------------------------------------------------
|
||||
local slug
|
||||
slug="$(printf '%s' "$title" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr -c 'a-z0-9' '_' \
|
||||
| sed -E 's/_+/_/g; s/^_//; s/_$//')"
|
||||
[[ -z "$slug" ]] && slug="agent"
|
||||
|
||||
local log="/tmp/orq_${slug}_kitty.log"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lanzar la kitty detached. El prompt se inyecta con "$(cat <prompt_file>)"
|
||||
# ya escapado para que se evalue DENTRO de la kitty, no aqui.
|
||||
# exec zsh deja una shell viva cuando el claude termina.
|
||||
# -----------------------------------------------------------------------
|
||||
local inner
|
||||
inner="claude --dangerously-skip-permissions \"\$(cat $(printf '%q' "$prompt_file"))\"; exec zsh"
|
||||
|
||||
setsid nohup kitty \
|
||||
--title "$title" \
|
||||
--directory "$directory" \
|
||||
zsh -ic "$inner" \
|
||||
>"$log" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Reportar. Con setsid el $! es el PID de setsid, no el de kitty; basta
|
||||
# con confirmar el lanzamiento y apuntar al log donde se ve el arranque.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "launch_claude_agent_kitty: claude secundario lanzado."
|
||||
echo " title: $title"
|
||||
echo " directory: $directory"
|
||||
echo " prompt_file: $prompt_file"
|
||||
echo " log: $log"
|
||||
echo " (sigue el arranque con: tail -f $(printf '%q' "$log"))"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
launch_claude_agent_kitty "$@"
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: list_claude_agents
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "list_claude_agents([--json] [--exclude-current] [-h|--help])"
|
||||
description: "Lista todas las instancias de Claude Code VIVAS cruzando pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json. Para cada claude vivo y validado devuelve PID, status (idle/busy), etime (tiempo de vida), KITTY_PID de su ventana kitty, sessionId y cwd. Es la herramienta de seguimiento de la flota del modo orquestador: el Claude principal ve que agentes secundarios siguen vivos, en que directorio trabajan y su sessionId para retomarlos con claude --resume."
|
||||
tags: [orchestration, claude, session, fleet, kitty, infra, terminal-capture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--json"
|
||||
desc: "Imprime un array JSON (un objeto por agente con pid, session_id, cwd, status, etime, kitty_pid, self) en vez de la tabla legible. Pensado para que el agente parsee y decida cual retomar/parar."
|
||||
- name: "--exclude-current"
|
||||
desc: "Omite la propia sesion del listado. Detecta el claude propio subiendo por la cadena de ancestros de $$ hasta hallar un proceso con comm=claude. Sin esta opcion, la sesion actual se marca (columna SELF en tabla / self=true en JSON)."
|
||||
- name: "-h|--help"
|
||||
desc: "Muestra el uso y termina con exit 0."
|
||||
output: "En modo tabla: una fila por claude vivo y validado con columnas PID, STATUS, ETIME, KITTY, SELF, SESSION_ID, CWD. En modo --json: array JSON con pid, session_id, cwd, status, etime, kitty_pid (null si no corre en kitty) y self. Si no hay claudes vivos imprime aviso (tabla) o [] (json) y exit 0. Exit 0 normal; exit 2 si flag invalido."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/list_claude_agents.sh"
|
||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart (parseado con python3); validacion en tres capas: kill -0 <PID> exito, el JSON existe, y anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat (si difieren el JSON es huerfano de un PID reusado y se omite). KITTY_PID se saca del environ del proceso (tr '\\0' '\\n' < /proc/<PID>/environ | sed -n 's/^KITTY_PID=//p'). etime via ps -o etime= -p <PID>. Reusa la misma logica de descubrimiento y validacion que reboot_all_claudes_bash_infra. El codigo JSON va en python3 -c con los datos por stdin TSV (no heredoc) para no colisionar el stdin del pipe."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Tabla legible de la flota de Claudes vivos (PID, status, etime, kitty, sessionId, cwd).
|
||||
./fn run list_claude_agents
|
||||
|
||||
# Array JSON para parsear (decidir cual retomar con claude --resume <session_id>).
|
||||
./fn run list_claude_agents --json
|
||||
|
||||
# Omitir la propia sesion (ver solo los agentes secundarios).
|
||||
./fn run list_claude_agents --exclude-current
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el orquestador necesita ver la flota de Claudes secundarios vivos (PID, cwd, sessionId, status) para seguir su progreso o decidir cual retomar/parar. Lanzala al inicio de un ciclo de seguimiento para saber que agentes siguen activos y en que directorio trabaja cada uno; usa `--json` cuando vayas a programar la decision (filtrar por `status`, extraer `session_id` para un `claude --resume`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere Claude Code >= 2.1.x.** Depende de que cada sesion escriba `~/.claude/sessions/<PID>.json` con los campos `sessionId`, `cwd`, `status`, `procStart`. Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
||||
- **Un JSON puede ser huerfano por PID reciclado.** El sistema operativo reusa PIDs; un `<PID>.json` viejo puede apuntar a un proceso `claude` distinto. Por eso se valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide la entrada se descarta. Sin esa validacion se reportarian agentes fantasma.
|
||||
- **El titulo exacto de la ventana kitty no se recupera sin `kitty @`.** Se reporta el `KITTY_PID` (suficiente para identificar la ventana); mapearlo al titulo requeriria `kitty @ ls`, que solo funciona si el control remoto de kitty esta habilitado. KISS: se omite por defecto. Un claude que corra fuera de kitty (terminal integrado de un editor, etc.) sale con `KITTY` vacio `(none)` / `kitty_pid: null`.
|
||||
- **Solo ve procesos del usuario actual.** `pgrep -x claude` y la lectura de `/proc/<PID>/{environ,stat}` solo cubren los claudes del propio usuario; no lista sesiones de otros usuarios del sistema.
|
||||
- **`status` refleja el ultimo estado guardado en el JSON**, no necesariamente el instante exacto de la consulta (Claude actualiza el archivo al cambiar de estado). Pueden aparecer valores como `idle`, `busy` o `waiting`.
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
# list_claude_agents — Lista todas las instancias de Claude Code VIVAS cruzando
|
||||
# pgrep -x claude con los archivos de estado por proceso ~/.claude/sessions/<PID>.json.
|
||||
# Para cada claude vivo y validado reporta: PID, status (idle/busy), etime (tiempo de
|
||||
# vida del proceso), KITTY_PID de la ventana kitty si corre en una, sessionId y cwd.
|
||||
# Es la herramienta de "seguimiento de la flota" del modo orquestador: el Claude
|
||||
# principal la usa para ver que agentes secundarios siguen vivos, en que directorio
|
||||
# trabajan y su sessionId (para poder retomarlos con claude --resume <sessionId>).
|
||||
#
|
||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito y el JSON debe existir.
|
||||
# - KITTY_PID del environ del proceso -> ventana kitty (titulo exacto requeriria
|
||||
# 'kitty @ ls'; aqui se reporta el KITTY_PID, suficiente para identificarla).
|
||||
# - etime via ps -o etime= -p <PID>.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
list_claude_agents() {
|
||||
local output="table" # table | json
|
||||
local exclude_current=0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
output="json"
|
||||
;;
|
||||
--exclude-current)
|
||||
exclude_current=1
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: list_claude_agents [--json] [--exclude-current]
|
||||
|
||||
Lista las instancias de Claude Code vivas y validas, una fila por agente, con su
|
||||
PID, status, etime (tiempo de vida), KITTY_PID, sessionId y cwd. Pensada para el
|
||||
modo orquestador: ver la flota de Claudes secundarios y su sessionId para retomar
|
||||
(claude --resume <sessionId>) o decidir cual parar.
|
||||
|
||||
Opciones:
|
||||
--json Imprime un array JSON (pid, session_id, cwd, status, etime,
|
||||
kitty_pid) en vez de la tabla. Util para parsear.
|
||||
--exclude-current Omite la propia sesion (sube por la cadena de ancestros de
|
||||
$$ hasta hallar un proceso con comm=claude). Sin esta opcion,
|
||||
la sesion actual se marca con self=true / SELF en la tabla.
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
list_claude_agents
|
||||
list_claude_agents --json
|
||||
list_claude_agents --exclude-current
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "list_claude_agents: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
||||
# Se usa tanto para --exclude-current como para marcar la fila propia.
|
||||
# -----------------------------------------------------------------------
|
||||
local current_claude_pid=""
|
||||
local walk="$$"
|
||||
local guard=0
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
local comm=""
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
current_claude_pid="$walk"
|
||||
break
|
||||
fi
|
||||
# campo 4 de /proc/<pid>/stat es el PPID
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Recolectar las sesiones vivas y validarlas.
|
||||
# -----------------------------------------------------------------------
|
||||
local sessions_dir="$HOME/.claude/sessions"
|
||||
local pids=""
|
||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Arrays paralelos con la flota validada.
|
||||
local -a a_pid a_status a_etime a_kitty a_sid a_cwd a_self
|
||||
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
# Validacion 1: el proceso debe seguir vivo.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validacion 2: debe existir su JSON de sesion.
|
||||
local json="$sessions_dir/$pid.json"
|
||||
if [[ ! -f "$json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
||||
# Salida: lineas "clave=valor" en orden fijo.
|
||||
local parsed=""
|
||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
print("sessionId=" + str(d.get("sessionId", "")))
|
||||
print("cwd=" + str(d.get("cwd", "")))
|
||||
print("status=" + str(d.get("status", "")))
|
||||
print("procStart=" + str(d.get("procStart", "")))
|
||||
PY
|
||||
)"
|
||||
[[ -z "$parsed" ]] && continue
|
||||
|
||||
local sid cwd status proc_start_json
|
||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
||||
|
||||
[[ -z "$sid" ]] && continue
|
||||
|
||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
||||
# con el campo 22 de /proc/<PID>/stat.
|
||||
local proc_start_real=""
|
||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
||||
# JSON huerfano de un PID reciclado: omitir.
|
||||
continue
|
||||
fi
|
||||
|
||||
# Omitir la propia sesion si se pidio --exclude-current.
|
||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
||||
local kitty_pid=""
|
||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
||||
|
||||
# etime: tiempo transcurrido desde que arranco el proceso.
|
||||
local etime=""
|
||||
etime="$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
||||
|
||||
# Marca de sesion propia (solo relevante cuando NO se excluye).
|
||||
local self="false"
|
||||
if [[ -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
self="true"
|
||||
fi
|
||||
|
||||
a_pid+=("$pid")
|
||||
a_status+=("${status:-?}")
|
||||
a_etime+=("${etime:-?}")
|
||||
a_kitty+=("${kitty_pid:-}")
|
||||
a_sid+=("$sid")
|
||||
a_cwd+=("${cwd:-?}")
|
||||
a_self+=("$self")
|
||||
done
|
||||
|
||||
local total="${#a_pid[@]}"
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
if [[ "$output" == "json" ]]; then
|
||||
echo "[]"
|
||||
else
|
||||
echo "list_claude_agents: ninguna sesion valida encontrada (PIDs huerfanos, reciclados, o sin JSON)."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida JSON.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ "$output" == "json" ]]; then
|
||||
# Delegar el escaping correcto de strings (cwd con espacios, etc.) a python3.
|
||||
# El codigo python va en -c y los datos por stdin (TSV), para no colisionar
|
||||
# el heredoc con el stdin del pipe.
|
||||
local i
|
||||
{
|
||||
for ((i = 0; i < total; i++)); do
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]}" \
|
||||
"${a_self[$i]}"
|
||||
done
|
||||
} | python3 -c '
|
||||
import json, sys
|
||||
out = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
pid, sid, cwd, status, etime, kitty, self_ = line.split("\t")
|
||||
out.append({
|
||||
"pid": int(pid) if pid.isdigit() else pid,
|
||||
"session_id": sid,
|
||||
"cwd": cwd,
|
||||
"status": status,
|
||||
"etime": etime,
|
||||
"kitty_pid": (int(kitty) if kitty.isdigit() else (kitty or None)),
|
||||
"self": (self_ == "true"),
|
||||
})
|
||||
print(json.dumps(out, indent=2))
|
||||
'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Salida tabla legible.
|
||||
# -----------------------------------------------------------------------
|
||||
echo "list_claude_agents — claudes vivos: ${total}"
|
||||
echo
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"PID" "STATUS" "ETIME" "KITTY" "SELF" "SESSION_ID" "CWD"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"--------" "-------" "------------" "---------" "------" \
|
||||
"--------------------------------------" "---"
|
||||
|
||||
local i
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local self_mark=""
|
||||
[[ "${a_self[$i]}" == "true" ]] && self_mark="SELF"
|
||||
printf '%-8s %-7s %-12s %-9s %-6s %-38s %s\n' \
|
||||
"${a_pid[$i]}" \
|
||||
"${a_status[$i]}" \
|
||||
"${a_etime[$i]}" \
|
||||
"${a_kitty[$i]:-(none)}" \
|
||||
"${self_mark:--}" \
|
||||
"${a_sid[$i]}" \
|
||||
"${a_cwd[$i]}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
list_claude_agents "$@"
|
||||
fi
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: reboot_all_claudes
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])"
|
||||
description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume <sessionId>). Mapea cada PID vivo a su ~/.claude/sessions/<PID>.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada."
|
||||
tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--go"
|
||||
desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run."
|
||||
- name: "--yes"
|
||||
desc: "Alias de --go."
|
||||
- name: "--resume-mode <resume|continue|none>"
|
||||
desc: "Estrategia de reanudacion. resume (default): claude --resume <sessionId>. continue: claude --continue. none: sesion nueva en el mismo cwd."
|
||||
- name: "--exclude-current"
|
||||
desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude."
|
||||
- name: "--only-idle"
|
||||
desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy."
|
||||
- name: "-h|--help"
|
||||
desc: "Muestra el uso y termina."
|
||||
output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/reboot_all_claudes.sh"
|
||||
notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/<PID>.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc/<PID>/stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory <cwd> zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Dry-run (default seguro): ver el plan sin tocar nada.
|
||||
reboot_all_claudes
|
||||
|
||||
# Reiniciar de verdad todas las sesiones MENOS la terminal actual.
|
||||
reboot_all_claudes --go --exclude-current
|
||||
|
||||
# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad.
|
||||
reboot_all_claudes --go --only-idle
|
||||
|
||||
# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd.
|
||||
reboot_all_claudes --go --resume-mode none
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes.<pid>.<ts>.sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual.
|
||||
- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas.
|
||||
- **Depende de `~/.claude/sessions/<PID>.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones.
|
||||
- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`.
|
||||
- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc/<PID>/stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana.
|
||||
Executable
+356
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env bash
|
||||
# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code
|
||||
# corriendo y las relanza retomando exactamente la sesion que tenian
|
||||
# (claude --resume <sessionId>). Por defecto es DRY-RUN: imprime el plan sin
|
||||
# tocar nada. Usar --go para ejecutarlo de verdad.
|
||||
#
|
||||
# Mecanismo (Claude Code 2.1.x sobre Linux + kitty):
|
||||
# - pgrep -x claude -> PIDs de las sesiones interactivas vivas.
|
||||
# - ~/.claude/sessions/<PID>.json -> mapea PID a {sessionId, cwd, status, procStart}.
|
||||
# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de
|
||||
# /proc/<PID>/stat; ademas kill -0 <PID> debe tener exito.
|
||||
# - KITTY_PID del environ del proceso -> ventana kitty a cerrar.
|
||||
# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos).
|
||||
# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal.
|
||||
set -euo pipefail
|
||||
IFS=$' \t\n'
|
||||
|
||||
reboot_all_claudes() {
|
||||
local mode="dry" # dry | go
|
||||
local resume_mode="resume" # resume | continue | none
|
||||
local exclude_current=0
|
||||
local only_idle=0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Parseo de argumentos
|
||||
# -----------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--go|--yes)
|
||||
mode="go"
|
||||
;;
|
||||
--resume-mode)
|
||||
shift
|
||||
resume_mode="${1:-}"
|
||||
case "$resume_mode" in
|
||||
resume|continue|none) ;;
|
||||
*)
|
||||
echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
--exclude-current)
|
||||
exclude_current=1
|
||||
;;
|
||||
--only-idle)
|
||||
only_idle=1
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
Uso: reboot_all_claudes [opciones]
|
||||
|
||||
Cierra todas las terminales con una sesion de Claude Code corriendo y las
|
||||
relanza retomando la misma sesion (claude --resume <sessionId>).
|
||||
|
||||
Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan
|
||||
y NO mata ni relanza nada.
|
||||
|
||||
Opciones:
|
||||
--go, --yes Ejecuta de verdad (kills + relanzamientos detached).
|
||||
--resume-mode <modo> resume (default) | continue | none.
|
||||
resume -> claude --resume <sessionId>
|
||||
continue -> claude --continue
|
||||
none -> claude (sesion nueva en el mismo cwd)
|
||||
--exclude-current No cierra ni relanza la terminal desde la que se invoca.
|
||||
--only-idle Omite sesiones con status busy (no pierde turnos en vuelo).
|
||||
-h, --help Muestra esta ayuda.
|
||||
|
||||
Ejemplos:
|
||||
reboot_all_claudes # dry-run, ve el plan
|
||||
reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detectar el PID de la sesion actual subiendo por la cadena de ancestros
|
||||
# hasta encontrar un proceso cuyo comm sea exactamente "claude".
|
||||
# -----------------------------------------------------------------------
|
||||
local current_claude_pid=""
|
||||
if [[ "$exclude_current" -eq 1 ]]; then
|
||||
local walk="$$"
|
||||
local guard=0
|
||||
while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do
|
||||
local comm=""
|
||||
comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)"
|
||||
if [[ "$comm" == "claude" ]]; then
|
||||
current_claude_pid="$walk"
|
||||
break
|
||||
fi
|
||||
# campo 4 de /proc/<pid>/stat es el PPID
|
||||
walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)"
|
||||
guard=$((guard + 1))
|
||||
[[ "$guard" -gt 64 ]] && break
|
||||
done
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Recolectar las sesiones vivas y validarlas.
|
||||
# -----------------------------------------------------------------------
|
||||
local sessions_dir="$HOME/.claude/sessions"
|
||||
local pids=""
|
||||
pids="$(pgrep -x claude 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Arrays paralelos con el plan validado.
|
||||
local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason
|
||||
|
||||
local pid
|
||||
for pid in $pids; do
|
||||
# Validacion 1: el proceso debe seguir vivo.
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validacion 2: debe existir su JSON de sesion.
|
||||
local json="$sessions_dir/$pid.json"
|
||||
if [[ ! -f "$json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parsear el JSON con python3 (campos sessionId, cwd, status, procStart).
|
||||
# Salida: lineas "clave=valor" en orden fijo.
|
||||
local parsed=""
|
||||
parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as fh:
|
||||
d = json.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
print("sessionId=" + str(d.get("sessionId", "")))
|
||||
print("cwd=" + str(d.get("cwd", "")))
|
||||
print("status=" + str(d.get("status", "")))
|
||||
print("procStart=" + str(d.get("procStart", "")))
|
||||
PY
|
||||
)"
|
||||
[[ -z "$parsed" ]] && continue
|
||||
|
||||
local sid cwd status proc_start_json
|
||||
sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')"
|
||||
cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')"
|
||||
status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')"
|
||||
proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')"
|
||||
|
||||
[[ -z "$sid" ]] && continue
|
||||
|
||||
# Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir
|
||||
# con el campo 22 de /proc/<PID>/stat.
|
||||
local proc_start_real=""
|
||||
proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)"
|
||||
if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then
|
||||
# JSON huerfano de un PID reciclado: omitir.
|
||||
continue
|
||||
fi
|
||||
|
||||
# KITTY_PID de la ventana kitty (vacio si claude no corre en kitty).
|
||||
local kitty_pid=""
|
||||
kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)"
|
||||
|
||||
# Flags originales: leer cmdline, descartar argv0 (claude) y cualquier
|
||||
# flag de resume/continue previo para no duplicarlos.
|
||||
local raw_cmd=""
|
||||
raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)"
|
||||
local -a kept_flags=()
|
||||
local first=1 tok skipnext=0
|
||||
while IFS= read -r tok; do
|
||||
[[ -z "$tok" ]] && continue
|
||||
if [[ "$first" -eq 1 ]]; then
|
||||
# argv0 (la ruta o nombre de claude) — descartar.
|
||||
first=0
|
||||
continue
|
||||
fi
|
||||
if [[ "$skipnext" -eq 1 ]]; then
|
||||
skipnext=0
|
||||
continue
|
||||
fi
|
||||
case "$tok" in
|
||||
--resume|--continue|-r|-c)
|
||||
# Resume/continue previos: omitir (y su posible valor para --resume).
|
||||
if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then
|
||||
skipnext=1
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
kept_flags+=("$tok")
|
||||
done <<< "$raw_cmd"
|
||||
|
||||
# Construir la estrategia de resume.
|
||||
local -a launch_args=()
|
||||
case "$resume_mode" in
|
||||
resume) launch_args=("--resume" "$sid") ;;
|
||||
continue) launch_args=("--continue") ;;
|
||||
none) launch_args=() ;;
|
||||
esac
|
||||
launch_args+=("${kept_flags[@]}")
|
||||
|
||||
# Comando claude final (para mostrar y ejecutar).
|
||||
local claude_cmd="claude"
|
||||
local a
|
||||
for a in "${launch_args[@]}"; do
|
||||
claude_cmd+=" $(printf '%q' "$a")"
|
||||
done
|
||||
|
||||
# Decidir si se omite esta sesion del plan.
|
||||
local skip=0 skipreason=""
|
||||
if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then
|
||||
skip=1
|
||||
skipreason="terminal actual (--exclude-current)"
|
||||
elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then
|
||||
skip=1
|
||||
skipreason="busy (--only-idle)"
|
||||
fi
|
||||
|
||||
plan_pid+=("$pid")
|
||||
plan_kitty+=("${kitty_pid:-}")
|
||||
plan_status+=("${status:-?}")
|
||||
plan_cwd+=("${cwd:-?}")
|
||||
plan_sid+=("$sid")
|
||||
plan_cmd+=("$claude_cmd")
|
||||
plan_skip+=("$skip")
|
||||
plan_skipreason+=("$skipreason")
|
||||
done
|
||||
|
||||
local total="${#plan_pid[@]}"
|
||||
if [[ "$total" -eq 0 ]]; then
|
||||
echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Imprimir el plan (siempre, tanto en dry-run como en --go).
|
||||
# -----------------------------------------------------------------------
|
||||
echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}"
|
||||
echo
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD"
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---"
|
||||
|
||||
local i busy_count=0 act_count=0
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local accion="reinic"
|
||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
||||
accion="OMITE"
|
||||
else
|
||||
act_count=$((act_count + 1))
|
||||
fi
|
||||
[[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1))
|
||||
printf '%-8s %-9s %-7s %-6s %-38s %s\n' \
|
||||
"${plan_pid[$i]}" \
|
||||
"${plan_kitty[$i]:-(none)}" \
|
||||
"${plan_status[$i]}" \
|
||||
"$accion" \
|
||||
"${plan_sid[$i]}" \
|
||||
"${plan_cwd[$i]}"
|
||||
if [[ "${plan_skip[$i]}" -eq 1 ]]; then
|
||||
echo " -> omitida: ${plan_skipreason[$i]}"
|
||||
else
|
||||
echo " -> ${plan_cmd[$i]}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# Aviso explicito de sesiones busy que SI se van a reiniciar.
|
||||
if [[ "$only_idle" -eq 0 ]]; then
|
||||
local warned=0
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then
|
||||
if [[ "$warned" -eq 0 ]]; then
|
||||
echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo"
|
||||
echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):"
|
||||
warned=1
|
||||
fi
|
||||
echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}"
|
||||
fi
|
||||
done
|
||||
[[ "$warned" -eq 1 ]] && echo
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# DRY-RUN: parar aqui.
|
||||
# -----------------------------------------------------------------------
|
||||
if [[ "$mode" == "dry" ]]; then
|
||||
echo "DRY-RUN: no se ha matado ni relanzado nada."
|
||||
echo "Para ejecutar de verdad: reboot_all_claudes --go"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$act_count" -eq 0 ]]; then
|
||||
echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MODO --go: construir un script desacoplado que mata las ventanas y
|
||||
# relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre
|
||||
# de la propia terminal (que es una de las victimas).
|
||||
# -----------------------------------------------------------------------
|
||||
local ts script log
|
||||
ts="$(date +%s)"
|
||||
script="/tmp/reboot_all_claudes.$$.$ts.sh"
|
||||
log="/tmp/reboot_all_claudes.$ts.log"
|
||||
|
||||
{
|
||||
echo '#!/usr/bin/env bash'
|
||||
echo 'set -uo pipefail'
|
||||
echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.'
|
||||
echo 'sleep 1'
|
||||
echo
|
||||
for ((i = 0; i < total; i++)); do
|
||||
[[ "${plan_skip[$i]}" -eq 1 ]] && continue
|
||||
local kp="${plan_kitty[$i]}"
|
||||
local cp="${plan_pid[$i]}"
|
||||
local cwd="${plan_cwd[$i]}"
|
||||
local cmd="${plan_cmd[$i]}"
|
||||
echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---"
|
||||
if [[ -n "$kp" ]]; then
|
||||
# Cerrar la ventana kitty limpia con SIGTERM.
|
||||
echo "kill $(printf '%q' "$kp") 2>/dev/null || true"
|
||||
else
|
||||
# Sin kitty: matar el propio claude.
|
||||
echo "kill $(printf '%q' "$cp") 2>/dev/null || true"
|
||||
fi
|
||||
# Relanzar en una kitty nueva, detached, en el cwd correcto.
|
||||
# zsh -ic '...; exec zsh' replica el patron del usuario: al salir de
|
||||
# claude queda una shell interactiva viva.
|
||||
printf 'setsid kitty --directory %q zsh -ic %q </dev/null >/dev/null 2>&1 &\n' \
|
||||
"$cwd" "${cmd}; exec zsh"
|
||||
echo
|
||||
done
|
||||
echo 'exit 0'
|
||||
} > "$script"
|
||||
|
||||
chmod +x "$script"
|
||||
echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)"
|
||||
setsid bash "$script" </dev/null >>"$log" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s."
|
||||
return 0
|
||||
}
|
||||
|
||||
# Permitir ejecutar el archivo directamente (no solo como funcion sourced).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
reboot_all_claudes "$@"
|
||||
fi
|
||||
@@ -3,12 +3,12 @@ name: write_mcp_jupyter_config
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
purity: impure
|
||||
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
|
||||
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."
|
||||
description: "Genera o actualiza .mcp.json para un analisis Jupyter. La entrada jupyter usa el wrapper jupyter_mcp_serve.sh con env overrides (venv, root y puerto del analisis), de modo que el MCP arranca su propio Jupyter con el venv del analisis. Merge con jq reemplazando la entrada jupyter entera."
|
||||
tags: [mcp, jupyter, config, setup, infra, notebook]
|
||||
uses_functions: []
|
||||
uses_functions: [jupyter_mcp_serve_bash_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
@@ -16,9 +16,9 @@ error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: project_dir
|
||||
desc: "directorio del proyecto Jupyter (default: directorio actual)"
|
||||
desc: "directorio del proyecto/analisis Jupyter (default: directorio actual)"
|
||||
- name: port
|
||||
desc: "puerto Jupyter (default: detectado automáticamente)"
|
||||
desc: "puerto Jupyter del analisis (default: 8888)"
|
||||
output: "ruta del archivo .mcp.json generado o actualizado"
|
||||
tested: false
|
||||
tests: []
|
||||
@@ -33,25 +33,33 @@ source write_mcp_jupyter_config.sh
|
||||
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",""]
|
||||
# "command": "bash"
|
||||
# "args": [".../bash/functions/infra/jupyter_mcp_serve.sh"]
|
||||
# "env": {
|
||||
# "JUPYTER_MCP_VENV": ".../analysis/finanzas/.venv",
|
||||
# "JUPYTER_MCP_ROOT": ".../analysis/finanzas",
|
||||
# "JUPYTER_MCP_PORT": "8890",
|
||||
# "JUPYTER_MCP_TOKEN": ""
|
||||
# }
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- 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`).
|
||||
- Para reparar un `.mcp.json` con el comando viejo (console-script directo que no arranca Jupyter, o `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.
|
||||
- **Usa el wrapper, no el console-script directo**: el `.mcp.json` apunta a `jupyter_mcp_serve.sh` (ver `jupyter_mcp_serve_bash_infra`), que arranca (o reusa) el Jupyter del analisis con su venv antes de exec del MCP. Con el console-script directo (`jupyter-mcp-server --jupyter-url ...`) el MCP solo se CONECTA: si el server no esta levantado no hay kernel y las operaciones sobre notebooks fallan. Con el wrapper basta abrir Claude desde el analisis — no hace falta lanzar `run-jupyter-lab.sh` aparte.
|
||||
- **El venv del kernel es el del analisis** (`JUPYTER_MCP_VENV`), no `python/.venv` del repo. Asi cada analisis ejecuta con sus propias dependencias sin contaminar el venv canonico. Este fix nacio de un caso real (analisis `nats`): trabajar desde la raiz de `fn_registry` cargaba el MCP global (8899, venv `python/.venv`) que no tenia `nats-py`.
|
||||
- **Reuso por puerto**: si ya hay un Jupyter escuchando en `JUPYTER_MCP_PORT` (p.ej. lanzado por `run-jupyter-lab.sh`, que es colaborativo), el wrapper lo reusa en vez de arrancar otro. Si no hay ninguno, el wrapper levanta uno propio (sin `--collaborative`, suficiente para el MCP). Para colaboracion humana en tiempo real, lanzar `run-jupyter-lab.sh` antes.
|
||||
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; importa y sale 0, el MCP nunca arranca. El entrypoint real es el console-script `jupyter-mcp-server`, que el wrapper localiza dentro del venv del analisis.
|
||||
- **Requiere `jupyter-mcp-server` instalado en el venv del analisis**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
|
||||
- **Localiza el wrapper subiendo directorios** desde `project_dir` (hasta 8 niveles) buscando `bash/functions/infra/jupyter_mcp_serve.sh`; si no lo encuentra, usa `FN_REGISTRY_ROOT`. Aborta si no aparece por ninguna via.
|
||||
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas (p.ej. el bloque `args` del console-script directo).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.2.0 (2026-06-03) — el `.mcp.json` generado usa el wrapper `jupyter_mcp_serve.sh` con env overrides (`JUPYTER_MCP_VENV/ROOT/PORT/TOKEN`) en vez del console-script directo. Garantiza que el MCP arranca su propio Jupyter con el venv del analisis (antes solo conectaba y usaba el venv equivocado si se abria Claude desde la raiz del repo). Declara dependencia `jupyter_mcp_serve_bash_infra`.
|
||||
- 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,21 +1,32 @@
|
||||
# write_mcp_jupyter_config
|
||||
# -------------------------
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
|
||||
# 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).
|
||||
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server para un
|
||||
# analisis/proyecto. La entrada `jupyter` usa el wrapper `jupyter_mcp_serve.sh`
|
||||
# (no el console-script directo), de modo que el MCP SIEMPRE tiene servidor: el
|
||||
# wrapper arranca (o reusa) un Jupyter Lab en el puerto indicado usando el venv
|
||||
# del propio analisis y lo engancha al MCP por stdio.
|
||||
#
|
||||
# Por que el wrapper y no el console-script directo: el console-script
|
||||
# `jupyter-mcp-server --jupyter-url http://localhost:PORT` solo se CONECTA, no
|
||||
# arranca Jupyter. Si el server no esta levantado, el MCP responde `initialize`
|
||||
# pero no hay kernel y toda operacion sobre notebooks falla. El wrapper levanta el
|
||||
# server con el venv correcto (JUPYTER_MCP_VENV) antes de exec del MCP, asi que
|
||||
# abrir Claude desde el analisis basta — no hace falta lanzar run-jupyter-lab.sh
|
||||
# aparte. Si ya hay un Jupyter en ese puerto (p.ej. run-jupyter-lab.sh), lo reusa.
|
||||
#
|
||||
# Env overrides que se inyectan al wrapper (ver jupyter_mcp_serve.sh):
|
||||
# JUPYTER_MCP_VENV venv del analisis (su .venv, con jupyter + jupyter-mcp-server)
|
||||
# JUPYTER_MCP_ROOT root de notebooks = directorio del analisis
|
||||
# JUPYTER_MCP_PORT puerto del Jupyter gestionado
|
||||
# JUPYTER_MCP_TOKEN token (vacio: solo escucha en 127.0.0.1)
|
||||
#
|
||||
# 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).
|
||||
# server.py no tiene bloque __main__. El entrypoint real es el console-script
|
||||
# `jupyter-mcp-server` (que el wrapper localiza dentro del venv del analisis).
|
||||
#
|
||||
# USO (sourced):
|
||||
# source write_mcp_jupyter_config.sh
|
||||
# write_mcp_jupyter_config /path/to/project 8888
|
||||
# write_mcp_jupyter_config /path/to/analysis 8890
|
||||
|
||||
write_mcp_jupyter_config() {
|
||||
local project_dir="${1:-.}"
|
||||
@@ -31,23 +42,47 @@ write_mcp_jupyter_config() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar que el console-script esta instalado
|
||||
# Verificar que el console-script esta instalado en el venv del analisis
|
||||
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
|
||||
|
||||
# Localizar el wrapper jupyter_mcp_serve.sh subiendo desde el directorio del
|
||||
# analisis hasta la raiz del repo. Fallback a FN_REGISTRY_ROOT.
|
||||
local wrapper="" d="$abs_project"
|
||||
local i
|
||||
for i in 1 2 3 4 5 6 7 8; do
|
||||
if [ -f "$d/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
|
||||
wrapper="$d/bash/functions/infra/jupyter_mcp_serve.sh"
|
||||
break
|
||||
fi
|
||||
d="$(dirname "$d")"
|
||||
[ "$d" = "/" ] && break
|
||||
done
|
||||
if [ -z "$wrapper" ] && [ -n "${FN_REGISTRY_ROOT:-}" ] && [ -f "${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh" ]; then
|
||||
wrapper="${FN_REGISTRY_ROOT}/bash/functions/infra/jupyter_mcp_serve.sh"
|
||||
fi
|
||||
if [ -z "$wrapper" ]; then
|
||||
echo "write_mcp_jupyter_config: no encuentro bash/functions/infra/jupyter_mcp_serve.sh subiendo desde ${abs_project} ni en FN_REGISTRY_ROOT" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local new_config
|
||||
new_config=$(cat << EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"jupyter": {
|
||||
"command": "${mcp_bin}",
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"--transport", "stdio",
|
||||
"--jupyter-url", "http://localhost:${port}",
|
||||
"--jupyter-token", ""
|
||||
]
|
||||
"${wrapper}"
|
||||
],
|
||||
"env": {
|
||||
"JUPYTER_MCP_VENV": "${abs_project}/.venv",
|
||||
"JUPYTER_MCP_ROOT": "${abs_project}",
|
||||
"JUPYTER_MCP_PORT": "${port}",
|
||||
"JUPYTER_MCP_TOKEN": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +92,7 @@ EOF
|
||||
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
|
||||
# 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).
|
||||
# keys huerfanas de configs viejas (ej. flags `args` obsoletos).
|
||||
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
|
||||
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
|
||||
mv "${mcp_file}.tmp" "$mcp_file"
|
||||
|
||||
@@ -3,14 +3,15 @@ name: full_git_pull
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "full_git_pull() -> stdout: tabla resumen"
|
||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index y ejecuta fn sync."
|
||||
description: "Pull automatico de fn_registry + todos los sub-repos locales + submodules + fn sync. Descubre repos locales, stashea dirty trees antes de pullear, hace pull --ff-only, actualiza submodulos del repo principal, pulla ~/.password-store, regenera registry.db con fn index, ejecuta fn sync y reclona los sub-repos hijos faltantes de cada project (apps/analysis) via clone_project_subrepos."
|
||||
tags: [git, pull, sync, registry, pipeline, pendiente-usar]
|
||||
uses_functions:
|
||||
- discover_git_repos_bash_infra
|
||||
- git_pull_with_stash_bash_infra
|
||||
- clone_project_subrepos_bash_pipelines
|
||||
- pass_get_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -51,4 +52,10 @@ bash bash/functions/pipelines/full_git_pull.sh
|
||||
|
||||
## Notas
|
||||
|
||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. No clona repos faltantes: cada PC tiene el subset que le interesa (clonar manualmente si se necesita uno nuevo). Modo completamente no-interactivo.
|
||||
Solo hace pull fast-forward — nunca rebase ni merge automatico. Los repos con divergencia o conflicto de stash se listan al final del resumen para intervencion manual, pero el pipeline no aborta por ellos. Modo completamente no-interactivo.
|
||||
|
||||
Desde v1.1.0 SI reclona los sub-repos hijos faltantes de cada project: tras `fn sync` (que trae a `registry.db` las filas de apps/analysis de todos los PCs), itera los projects y llama `clone_project_subrepos` para traer al disco los hijos que falten, re-indexando si clono alguno. `registry.db` actua como manifest de sub-repos, asi que clonar el project paraguas + `/full-git-pull` reconstruye su arbol entero sin adivinar nombres. Los repos sueltos (sin project) siguen sin auto-clonarse: cada PC tiene el subset que le interesa.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-10) — anade el paso 6: reclonado de sub-repos hijos de cada project via `clone_project_subrepos` tras `fn sync`, con re-index si clona alguno. Permite reconstruir el arbol completo de un project en un PC nuevo (issue 0171).
|
||||
|
||||
@@ -149,6 +149,42 @@ full_git_pull() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Paso 6: Reclonar sub-repos hijos de cada project (issue 0171) ---
|
||||
# Tras fn sync, registry.db contiene las filas apps/analysis de TODOS los PCs.
|
||||
# clone_project_subrepos clona en este disco los hijos que falten (skip si ya
|
||||
# existen). Asi, clonar el project paraguas y correr /full-git-pull reconstruye
|
||||
# su arbol entero sin adivinar nombres de sub-repos: registry.db ES el manifest.
|
||||
echo "" >&2
|
||||
echo "[6/6] Reclonando sub-repos de projects..." >&2
|
||||
local reclone_summary=" [skip] sin projects o registry.db"
|
||||
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||
export FN_REGISTRY_ROOT="$registry_root"
|
||||
export GITEA_URL="${GITEA_URL:-$(pass_get agentes/gitea-url | head -n1 2>/dev/null || true)}"
|
||||
local clone_script="$SCRIPT_DIR/clone_project_subrepos.sh"
|
||||
local any_cloned=0
|
||||
if [[ -f "$clone_script" ]]; then
|
||||
while IFS= read -r proj_id; do
|
||||
[[ -z "$proj_id" ]] && continue
|
||||
local clone_out
|
||||
clone_out=$(bash "$clone_script" "$proj_id" 2>&1 || true)
|
||||
if echo "$clone_out" | grep -q '\[cloned\]'; then
|
||||
any_cloned=1
|
||||
echo " $proj_id: nuevos sub-repos clonados" >&2
|
||||
fi
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT id FROM projects;" 2>/dev/null)
|
||||
if [[ "$any_cloned" -eq 1 ]]; then
|
||||
echo " re-index tras clonado..." >&2
|
||||
[[ -x "$fn_bin" ]] && CGO_ENABLED=1 "$fn_bin" index >/dev/null 2>&1 || true
|
||||
reclone_summary=" OK: nuevos sub-repos clonados + re-index"
|
||||
else
|
||||
reclone_summary=" OK: nada que clonar (todo presente)"
|
||||
fi
|
||||
else
|
||||
reclone_summary=" [skip] clone_project_subrepos.sh no encontrado"
|
||||
fi
|
||||
fi
|
||||
echo " $reclone_summary" >&2
|
||||
|
||||
# --- Resumen ---
|
||||
echo ""
|
||||
echo "===== RESUMEN full_git_pull ====="
|
||||
@@ -171,6 +207,9 @@ full_git_pull() {
|
||||
echo ""
|
||||
echo "fn sync:"
|
||||
echo "$sync_summary"
|
||||
echo ""
|
||||
echo "Reclonado sub-repos de projects:"
|
||||
echo "$reclone_summary"
|
||||
|
||||
if [[ ${#diverged[@]} -gt 0 || ${#conflicts[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
|
||||
@@ -3,10 +3,10 @@ name: full_git_push
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "full_git_push(commit_message?: string) -> stdout: tabla resumen"
|
||||
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses sin .git via ensure_repo_synced, auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
||||
description: "Push automatico de fn_registry + todos los sub-repos + fn sync. Descubre repos, escanea secrets (aborta si detecta), auto-inicializa apps/analyses Y projects paraguas sin .git via ensure_repo_synced (asegurando el .gitignore canonico del project antes), auto-commitea dirty trees, pushea solo repos adelantados, pushea ~/.password-store sin commitear, y ejecuta fn sync."
|
||||
tags: [git, push, sync, registry, pipeline, pendiente-usar]
|
||||
uses_functions:
|
||||
- discover_git_repos_bash_infra
|
||||
@@ -14,6 +14,7 @@ uses_functions:
|
||||
- git_auto_commit_dirty_bash_infra
|
||||
- git_push_if_ahead_bash_infra
|
||||
- ensure_repo_synced_bash_infra
|
||||
- ensure_project_gitignore_bash_infra
|
||||
- pass_get_bash_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -62,3 +63,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
|
||||
## Notas
|
||||
|
||||
El unico motivo para abortar antes de commitear es la deteccion de secrets. Cualquier otro error (push rechazado por non-fast-forward, fn sync no disponible) se reporta en el resumen y el pipeline continua con el resto de repos. Modo completamente no-interactivo.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-10) — auto-inicializa tambien los projects paraguas (`projects/<p>/`) sin repo Gitea, no solo apps/analyses. Antes de pushear cada project asegura su `.gitignore` canonico via `ensure_project_gitignore` para no trackear el contenido de los sub-repos hijos. Cierra el agujero por el que projects como aurgi/obsidian/osint vivian solo en disco y se perdian al borrar el PC (issue 0171).
|
||||
|
||||
@@ -13,6 +13,7 @@ source "$INFRA_DIR/git_auto_commit_dirty.sh"
|
||||
source "$INFRA_DIR/git_push_if_ahead.sh"
|
||||
source "$INFRA_DIR/pass_get.sh"
|
||||
source "$INFRA_DIR/ensure_repo_synced.sh"
|
||||
source "$INFRA_DIR/ensure_project_gitignore.sh"
|
||||
source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
|
||||
|
||||
full_git_push() {
|
||||
@@ -65,6 +66,32 @@ full_git_push() {
|
||||
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)
|
||||
|
||||
# Paso 1c: Auto-inicializar los PROJECTS paraguas sin .git (issue 0171).
|
||||
# El directorio projects/<p>/ versiona SOLO las docs de nivel-project
|
||||
# (project.md, vault.yaml, CONVENTIONS.md, tools/...). Sus hijos apps/* y
|
||||
# analysis/* son sub-repos Gitea independientes, excluidos por el .gitignore
|
||||
# canonico que ensure_project_gitignore garantiza ANTES del push para no
|
||||
# trackear su contenido (doble-tracking). Sin esto, un project sin repo
|
||||
# (aurgi, obsidian, osint) vivia solo en disco y se perdia al borrar el PC.
|
||||
if [[ -f "$registry_root/registry.db" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
||||
while IFS= read -r proj_dir; do
|
||||
[[ -z "$proj_dir" ]] && continue
|
||||
local pd="$registry_root/$proj_dir"
|
||||
[[ -d "$pd" ]] || continue
|
||||
# Garantizar el .gitignore canonico ANTES de cualquier git add -A.
|
||||
ensure_project_gitignore "$pd" || \
|
||||
echo " [warn] no se pudo asegurar .gitignore de $pd" >&2
|
||||
if [[ -d "$pd/.git" ]]; then
|
||||
git -C "$pd" remote get-url origin >/dev/null 2>&1 && continue
|
||||
echo " fix-remote: $pd (.git sin origin)" >&2
|
||||
else
|
||||
echo " auto-init project: $pd" >&2
|
||||
fi
|
||||
ensure_repo_synced "$pd" dataforge "$(basename "$pd")" master "chore: initial sync project" || \
|
||||
echo " [warn] fallo inicializando project $pd" >&2
|
||||
done < <(sqlite3 "$registry_root/registry.db" "SELECT CASE WHEN dir_path != '' THEN dir_path ELSE 'projects/'||id END FROM projects;" 2>/dev/null)
|
||||
fi
|
||||
else
|
||||
echo " [warn] registry.db o sqlite3 no disponibles — omitiendo auto-init BD-driven" >&2
|
||||
fi
|
||||
@@ -72,28 +99,13 @@ full_git_push() {
|
||||
echo " [skip] GITEA_URL/GITEA_TOKEN no disponibles — omitiendo auto-init" >&2
|
||||
fi
|
||||
|
||||
# Redescubrir repos tras posibles inicializaciones
|
||||
# Redescubrir repos tras posibles inicializaciones.
|
||||
# El repo de config de Claude (dataforge/repo_Claude, al que apuntan los
|
||||
# symlinks de ~/.claude/) vive en fn_registry/external/repo_Claude, asi que
|
||||
# discover_git_repos ya lo encuentra y pasa por scan-secrets/commit/push
|
||||
# como un repo mas. No necesita tratamiento especial.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: reset_chrome_profiles
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reset_chrome_profiles --user-data-dir <dir> [--profile \"<dir>=<legible>\"]... [--backup-dir <dir>] [--base-port 9250] [--keep <ext_id>]... [--dry-run] [--yes]"
|
||||
description: "Pipeline de reset destructivo de perfiles de Chromium: hace backup de los bookmarks de todos los perfiles, cierra el chromium que use ese user-data-dir, borra los perfiles (carpeta + Local State), los recrea (la managed policy reinstala la whitelist de extensiones uBlock + web_proxy), restaura los bookmarks y verifica que cada perfil quedó solo con la whitelist. DESTRUCTIVO: se pierden cookies, logins, historial y contraseñas; solo los bookmarks se preservan. Requiere --yes en modo real."
|
||||
tags: [launcher, navegator, chromium, pipeline, profile, reset]
|
||||
uses_functions:
|
||||
- backup_chrome_bookmarks_bash_browser
|
||||
- delete_chrome_profile_bash_browser
|
||||
- create_chrome_profile_bash_browser
|
||||
- restore_chrome_bookmarks_bash_browser
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: "--user-data-dir <dir>"
|
||||
desc: "Raíz del user-data-dir de Chromium cuyos perfiles se resetean (ej. ~/.config/chromium-cdp)."
|
||||
- name: "--profile <dir=legible>"
|
||||
desc: "Perfil a resetear, formato carpeta=nombre-legible (repetible). Default los 4 reales: Default=Work, Personal=Personal, 'Profile 1'=Aurgi, Automation=Automation."
|
||||
- name: "--backup-dir <dir>"
|
||||
desc: "Directorio donde se guardan los backups de bookmarks. Default ~/.local/share/web_scraping/bookmarks-backups."
|
||||
- name: "--base-port <N>"
|
||||
desc: "Puerto CDP base para recrear perfiles (cada perfil usa base+i). Default 9250."
|
||||
- name: "--keep <ext_id>"
|
||||
desc: "ID de extensión esperada tras el reset (repetible). Default uBlock Origin Lite + web_proxy toggle. Solo se usa en la verificación final."
|
||||
- name: "--dry-run"
|
||||
desc: "Previsualiza los 6 pasos sin tocar el sistema."
|
||||
- name: "--yes"
|
||||
desc: "Confirma la operación destructiva (obligatorio en modo real)."
|
||||
output: "Ejecuta backup → cerrar chromium → delete → create → restore → verify. Emite el progreso de cada paso y un resumen. Sale 0 si todo OK y cada perfil quedó solo con la whitelist; != 0 si falla algún paso o la verificación detecta extensiones fuera de la whitelist."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/pipelines/reset_chrome_profiles.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Previsualizar el reset de los 4 perfiles del chromium diario (no toca nada)
|
||||
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --dry-run
|
||||
|
||||
# Reset real (destructivo): backup bookmarks, borrar+recrear los 4 perfiles, restaurar bookmarks
|
||||
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" --yes
|
||||
|
||||
# Reset de un solo perfil con nombre legible
|
||||
fn run reset_chrome_profiles --user-data-dir "$HOME/.config/chromium-cdp" \
|
||||
--profile "Automation=Automation" --yes
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras dejar los perfiles de un Chromium **limpios desde cero** conservando solo la whitelist de extensiones (uBlock + la de captura del web_proxy) y preservando los bookmarks, pero descartando todo el resto del estado (cookies, logins, historial). Útil para volver a un estado conocido de scraping/captura o para limpiar perfiles contaminados. La managed policy de `/etc` ya fuerza la whitelist, así que los perfiles recreados nacen correctos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **DESTRUCTIVO**: cookies, logins, historial y contraseñas de los perfiles se pierden de forma irreversible. Solo los bookmarks se preservan (backup + restore byte a byte). Por eso requiere `--yes` en modo real.
|
||||
- **Cierra el chromium del user-data-dir indicado** (pkill por `--user-data-dir`), no cualquier chromium. Si tienes otro chromium con otro user-data-dir, no se toca.
|
||||
- **Depende de la managed policy**: los perfiles recreados solo tendrán uBlock + web_proxy si la policy de `/etc/chromium/policies/managed/extensions.json` las fuerza (ver `apply_chromium_extension_policy_bash_browser`). Si la policy no está, los perfiles nacen sin extensiones.
|
||||
- La verificación final comprueba las carpetas en `<profile>/Extensions/`; para una auditoría detallada (nombre, versión, enabled, fromPolicy) usar `list_chrome_profile_extensions_go_browser`.
|
||||
- Lanzar chromium desde el Bash tool da exit-144; `create_chrome_profile` usa `systemd-run --user` internamente para evitarlo.
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# reset_chrome_profiles — Pipeline de reset destructivo de perfiles de Chromium.
|
||||
#
|
||||
# Compone funciones del registry para: hacer backup de los bookmarks de todos los perfiles,
|
||||
# cerrar chromium, borrar los perfiles (carpeta + entradas en Local State), recrearlos
|
||||
# (la managed policy reinstala la whitelist de extensiones: uBlock + web_proxy), restaurar
|
||||
# los bookmarks y verificar que cada perfil quedó solo con la whitelist.
|
||||
#
|
||||
# DESTRUCTIVO: borra cookies, logins, historial y contraseñas de los perfiles. Solo los
|
||||
# bookmarks se preservan (backup + restore). Requiere --yes en modo real (o --dry-run).
|
||||
#
|
||||
# Uso:
|
||||
# reset_chrome_profiles --user-data-dir <dir>
|
||||
# [--profile "<dir>=<legible>"]... [--backup-dir <dir>] [--base-port 9250]
|
||||
# [--keep <ext_id>]... [--dry-run] [--yes]
|
||||
#
|
||||
# Defaults de --profile (los 4 perfiles reales): "Default=Work" "Personal=Personal"
|
||||
# "Profile 1=Aurgi" "Automation=Automation".
|
||||
# Default de --keep (whitelist esperada tras el reset): uBlock Origin Lite + web_proxy toggle.
|
||||
|
||||
reset_chrome_profiles() {
|
||||
local _udd="" _backup_dir="${HOME}/.local/share/web_scraping/bookmarks-backups"
|
||||
local _base_port=9250 _dry_run=0 _yes=0
|
||||
local -a _profiles=()
|
||||
local -a _keep=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-data-dir) _udd="$2"; shift 2 ;;
|
||||
--profile) _profiles+=("$2"); shift 2 ;;
|
||||
--backup-dir) _backup_dir="$2"; shift 2 ;;
|
||||
--base-port) _base_port="$2"; shift 2 ;;
|
||||
--keep) _keep+=("$2"); shift 2 ;;
|
||||
--dry-run) _dry_run=1; shift ;;
|
||||
--yes) _yes=1; shift ;;
|
||||
-h|--help)
|
||||
grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; return 0 ;;
|
||||
*) echo "reset_chrome_profiles: argumento desconocido: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$_udd" ]]; then
|
||||
echo "reset_chrome_profiles: --user-data-dir es obligatorio" >&2; return 1
|
||||
fi
|
||||
if [[ ${#_profiles[@]} -eq 0 ]]; then
|
||||
_profiles=("Default=Work" "Personal=Personal" "Profile 1=Aurgi" "Automation=Automation")
|
||||
fi
|
||||
if [[ ${#_keep[@]} -eq 0 ]]; then
|
||||
_keep=("ddkjiahejlhfcafbddmgiahcphecmpfh" "nanldmckabfghgdebblpfbdbhphhbnde")
|
||||
fi
|
||||
|
||||
# Localizar las funciones del registry que componemos.
|
||||
local _dir _root _browser
|
||||
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
_root="$(cd "$_dir/../../.." && pwd)"
|
||||
_browser="$_root/bash/functions/browser"
|
||||
local _f
|
||||
for _f in backup_chrome_bookmarks restore_chrome_bookmarks delete_chrome_profile create_chrome_profile; do
|
||||
if [[ ! -f "$_browser/$_f.sh" ]]; then
|
||||
echo "reset_chrome_profiles: falta función $_f en $_browser" >&2; return 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$_browser/$_f.sh"
|
||||
done
|
||||
|
||||
echo "=== reset_chrome_profiles ==="
|
||||
echo " user-data-dir : $_udd"
|
||||
echo " perfiles : ${_profiles[*]}"
|
||||
echo " whitelist ext : ${_keep[*]}"
|
||||
echo " backup-dir : $_backup_dir"
|
||||
echo " modo : $([[ $_dry_run -eq 1 ]] && echo DRY-RUN || echo REAL)"
|
||||
echo ""
|
||||
|
||||
# Confirmación obligatoria en modo real.
|
||||
if [[ $_dry_run -eq 0 && $_yes -eq 0 ]]; then
|
||||
echo "reset_chrome_profiles: operación DESTRUCTIVA (se pierden cookies/logins/historial)." >&2
|
||||
echo " Repite con --yes para confirmar, o usa --dry-run para previsualizar." >&2
|
||||
return 3
|
||||
fi
|
||||
|
||||
# ── [1/6] Backup de bookmarks (solo lee; chromium puede estar abierto) ──────
|
||||
echo "[1/6] Backup de bookmarks..."
|
||||
local _bk_json _ts_dir
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir" --dry-run
|
||||
_ts_dir="<dry-run>"
|
||||
else
|
||||
_bk_json="$(backup_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_backup_dir")" || {
|
||||
echo "reset_chrome_profiles: backup falló" >&2; return 1; }
|
||||
echo "$_bk_json"
|
||||
_ts_dir="$(printf '%s' "$_bk_json" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["backup_dir"]+"/"+d["ts"])')"
|
||||
echo " backup en: $_ts_dir"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── [2/6] Cerrar chromium que tenga ESTE user-data-dir abierto ─────────────
|
||||
echo "[2/6] Cerrando chromium con --user-data-dir=$_udd ..."
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo " (dry-run: no se cierra nada)"
|
||||
else
|
||||
# Por-PID con comm=chromium (pgrep -x) para no auto-matchear grep/pgrep (el path del udd
|
||||
# contiene la cadena "chromium").
|
||||
local _p _kpids _i=0
|
||||
_kpids=""
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
|
||||
done
|
||||
if [[ -n "${_kpids// }" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill -TERM $_kpids 2>/dev/null || true
|
||||
while :; do
|
||||
_kpids=""
|
||||
for _p in $(pgrep -x chromium 2>/dev/null); do
|
||||
tr '\0' ' ' < "/proc/$_p/cmdline" 2>/dev/null | grep -qF -- "$_udd" && _kpids="$_kpids $_p"
|
||||
done
|
||||
[[ -z "${_kpids// }" ]] && break
|
||||
_i=$((_i+1)); [[ $_i -ge 20 ]] && { kill -9 $_kpids 2>/dev/null || true; break; }
|
||||
sleep 0.5
|
||||
done
|
||||
echo " chromium cerrado."
|
||||
else
|
||||
echo " (no había chromium con ese user-data-dir)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── [3/6] Borrar perfiles (carpeta + Local State) ──────────────────────────
|
||||
echo "[3/6] Borrando perfiles..."
|
||||
local _del_args=() _pair _pdir
|
||||
for _pair in "${_profiles[@]}"; do
|
||||
_pdir="${_pair%%=*}"
|
||||
_del_args+=(--profile "$_pdir")
|
||||
done
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" --dry-run
|
||||
else
|
||||
delete_chrome_profile --user-data-dir "$_udd" "${_del_args[@]}" || {
|
||||
echo "reset_chrome_profiles: delete falló" >&2; return 1; }
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── [4/6] Recrear perfiles (la policy reinstala la whitelist al arrancar) ───
|
||||
echo "[4/6] Recreando perfiles..."
|
||||
local _idx=0 _name _port
|
||||
for _pair in "${_profiles[@]}"; do
|
||||
_pdir="${_pair%%=*}"; _name="${_pair#*=}"; _port=$((_base_port + _idx))
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" --dry-run
|
||||
else
|
||||
create_chrome_profile --user-data-dir "$_udd" --profile "$_pdir" --name "$_name" --port "$_port" || {
|
||||
echo "reset_chrome_profiles: create de '$_pdir' falló" >&2; return 1; }
|
||||
fi
|
||||
_idx=$((_idx+1))
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ── [5/6] Restaurar bookmarks ──────────────────────────────────────────────
|
||||
echo "[5/6] Restaurando bookmarks..."
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo " (dry-run: restauraría desde el backup recién creado)"
|
||||
else
|
||||
restore_chrome_bookmarks --user-data-dir "$_udd" --backup-dir "$_ts_dir" || {
|
||||
echo "reset_chrome_profiles: restore falló (continúo a verify)" >&2; }
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── [6/6] Verificar extensiones por perfil (carpetas en Extensions/) ───────
|
||||
echo "[6/6] Verificando extensiones (esperado: solo la whitelist)..."
|
||||
if [[ $_dry_run -eq 1 ]]; then
|
||||
echo " (dry-run: verificaría que cada perfil tiene solo ${_keep[*]})"
|
||||
echo ""
|
||||
echo "reset_chrome_profiles: DRY-RUN completado, nada se modificó."
|
||||
return 0
|
||||
fi
|
||||
local _ok=1
|
||||
for _pair in "${_profiles[@]}"; do
|
||||
_pdir="${_pair%%=*}"
|
||||
local _extdir="$_udd/$_pdir/Extensions"
|
||||
local -a _present=()
|
||||
if [[ -d "$_extdir" ]]; then
|
||||
local _e
|
||||
for _e in "$_extdir"/*/; do
|
||||
_e="$(basename "$_e")"
|
||||
[[ "$_e" == "Temp" || "$_e" == "*" ]] && continue
|
||||
_present+=("$_e")
|
||||
done
|
||||
fi
|
||||
# Comprobar que todo lo presente está en la whitelist.
|
||||
local _extra=()
|
||||
local _id _found
|
||||
for _id in "${_present[@]}"; do
|
||||
_found=0
|
||||
local _k
|
||||
for _k in "${_keep[@]}"; do [[ "$_id" == "$_k" ]] && _found=1; done
|
||||
[[ $_found -eq 0 ]] && _extra+=("$_id")
|
||||
done
|
||||
if [[ ${#_extra[@]} -gt 0 ]]; then
|
||||
echo " ✗ $_pdir: extensiones fuera de whitelist: ${_extra[*]}"
|
||||
_ok=0
|
||||
else
|
||||
echo " ✓ $_pdir: ${_present[*]:-<vacío, aún sin arrancar>}"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
if [[ $_ok -eq 1 ]]; then
|
||||
echo "reset_chrome_profiles: OK — perfiles recreados, bookmarks restaurados, solo la whitelist presente."
|
||||
return 0
|
||||
else
|
||||
echo "reset_chrome_profiles: verificación con avisos (revisar arriba)." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
reset_chrome_profiles "$@"
|
||||
fi
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: close_onlyoffice_instance
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "close_onlyoffice_instance(instance: string = demo, [--purge]) -> json"
|
||||
description: "Termina el/los proceso(s) DesktopEditors de una INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su HOME=/tmp/oo_<instance> leido de /proc/<pid>/environ — asi NUNCA mata la instancia personal del usuario, solo la aislada. Envia SIGTERM, espera ~3s por evento (read -t, sin sleep foreground) y SIGKILL a los que sigan vivos. Con el flag --purge borra ademas los directorios del slot (/tmp/oo_<instance>*). Imprime JSON con instance, killed_pids (array), purged y status (closed|not_running)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado a cerrar (default: demo). Solo se matan procesos DesktopEditors cuyo HOME sea /tmp/oo_<instance>"
|
||||
- name: --purge
|
||||
desc: "flag opcional: si se pasa, borra los directorios del slot (/tmp/oo_<instance>*) tras matar los procesos. Sin el flag, solo termina procesos y deja el estado del slot en disco"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"killed_pids\":[<pids>],\"purged\":true|false,\"status\":\"closed\"|\"not_running\"}. Exit 0 siempre que opere bien (closed si mato procesos, not_running si no habia ninguno del slot), exit 1 si falta dependencia, exit 2 si flag desconocido"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/close_onlyoffice_instance.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Cerrar el slot demo (deja /tmp/oo_demo* en disco para reusar la config)
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh demo
|
||||
|
||||
# Cerrar y limpiar todo el estado del slot
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh demo --purge
|
||||
|
||||
# Slot por defecto (demo) sin argumentos
|
||||
bash bash/functions/shell/close_onlyoffice_instance.sh
|
||||
|
||||
# Via fn run
|
||||
./fn run close_onlyoffice_instance_bash_shell reporte --purge
|
||||
|
||||
# Sourceado
|
||||
source bash/functions/shell/close_onlyoffice_instance.sh
|
||||
out=$(close_onlyoffice_instance demo --purge)
|
||||
echo "$out"
|
||||
# {"instance":"demo","killed_pids":[12345,12350],"purged":true,"status":"closed"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando terminas un flujo automatizado con ONLYOFFICE Desktop y quieres **cerrar la instancia aislada por completo** (cerrar la ventana con `wmctrl` deja el proceso vivo; esta funcion mata el proceso real).
|
||||
- Para **liberar recursos** de un slot que ya no usas, opcionalmente borrando su estado en /tmp con `--purge`.
|
||||
- Como ultimo paso del ciclo open -> reload -> close, garantizando que no quedan procesos huerfanos de la instancia aislada.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Solo mata la instancia aislada**: identifica procesos por `HOME=/tmp/oo_<instance>` en `/proc/<pid>/environ`. La instancia personal del usuario (HOME real) NUNCA se toca. Esto es por diseño y por seguridad.
|
||||
- **Cerrar la ventana NO mata el proceso**: por eso esta funcion existe. Tras `reload`/`wmctrl -ic` el proceso de la instancia aislada sigue vivo (deseable para reusar). Usa esta funcion para terminarlo de verdad.
|
||||
- **`--purge` borra /tmp/oo_<instance>***: pierdes la config del slot (perfil, recientes). El slot se recreara limpio en el siguiente `open`. Sin `--purge`, el estado persiste y el siguiente arranque reusa esa config.
|
||||
- **El slot vive en /tmp**: aunque no purgues, `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||
- **Requiere X11 + wmctrl + xdotool** instalados (coherencia con el grupo, aunque esta funcion solo usa /proc para matar). Comprueba `command -v` y falla claro si falta alguna; no funciona en Wayland puro sin XWayland para el resto del grupo.
|
||||
- **Carrera de /proc**: si un pid muere entre listarlo y leer su environ, se ignora silenciosamente (guardas `2>/dev/null || true`); no rompe la funcion (`set -uo pipefail` sin `-e`).
|
||||
- **SIGKILL como ultimo recurso**: tras ~3s de SIGTERM, los procesos vivos reciben SIGKILL. Cambios sin guardar en la app (si los hubiera) se pierden — pero el flujo previsto edita en disco, no en la app, asi que no deberia haber estado sin guardar.
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# close_onlyoffice_instance — termina el/los proceso(s) DesktopEditors de una
|
||||
# INSTANCIA AISLADA (slot) de ONLYOFFICE Desktop Editors, identificados por su
|
||||
# HOME=/tmp/oo_<instance> en /proc/<pid>/environ. Opcionalmente limpia los
|
||||
# directorios del slot con --purge.
|
||||
#
|
||||
# Funcion impura: lee /proc, envia señales a procesos y (con --purge) borra
|
||||
# directorios bajo /tmp. NO toca la instancia personal del usuario: solo mata
|
||||
# procesos cuyo HOME apunta al slot aislado.
|
||||
#
|
||||
# Slot aislado: cada instance usa HOME=/tmp/oo_<instance>,
|
||||
# XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config.
|
||||
|
||||
# Sin -e: lecturas de /proc/<pid>/environ pueden fallar por carrera (el pid
|
||||
# muere entre listar y leer); no deben abortar la funcion.
|
||||
set -uo pipefail
|
||||
|
||||
close_onlyoffice_instance() {
|
||||
local instance="demo"
|
||||
local purge=false
|
||||
|
||||
# Parseo de args: [instance] y/o --purge en cualquier orden.
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--purge) purge=true ;;
|
||||
-*) echo "close_onlyoffice_instance: flag desconocido '$a'" >&2; return 2 ;;
|
||||
*) instance="$a" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 1. Dependencias del sistema (consistencia con el grupo, aunque aqui solo
|
||||
# se usa /proc; onlyoffice/wmctrl/xdotool deben existir para operar el slot).
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "close_onlyoffice_instance: falta dependencia '$dep'" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
|
||||
# 2. Encontrar pids de DesktopEditors con HOME=/tmp/oo_<instance>.
|
||||
local pids=() pid environ
|
||||
for pid in $(pgrep -f '/opt/onlyoffice/desktopeditors/DesktopEditors' 2>/dev/null || true); do
|
||||
# Leer el entorno del proceso; saltar si no se puede (carrera/permisos).
|
||||
environ=$(tr '\0' '\n' <"/proc/${pid}/environ" 2>/dev/null || true)
|
||||
[[ -z "$environ" ]] && continue
|
||||
if grep -qx "HOME=${oo_home}" <<<"$environ" 2>/dev/null; then
|
||||
pids+=("$pid")
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Si no hay procesos del slot: not_running (purge opcional igualmente).
|
||||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||
local purged=false
|
||||
if [[ "$purge" == true ]]; then
|
||||
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||
purged=true
|
||||
fi
|
||||
printf '{"instance":"%s","killed_pids":[],"purged":%s,"status":"not_running"}\n' \
|
||||
"$instance" "$purged"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. SIGTERM a todos los pids del slot.
|
||||
kill -TERM "${pids[@]}" 2>/dev/null || true
|
||||
|
||||
# 5. Esperar ~3s a que mueran (NUNCA sleep foreground): read -t 0.3 x10.
|
||||
local w=0 wmax=10
|
||||
while [[ $w -lt $wmax ]]; do
|
||||
local alive=false p
|
||||
for p in "${pids[@]}"; do
|
||||
if kill -0 "$p" 2>/dev/null; then alive=true; break; fi
|
||||
done
|
||||
[[ "$alive" == false ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
w=$((w + 1))
|
||||
done
|
||||
|
||||
# 6. SIGKILL a los que sigan vivos.
|
||||
local p
|
||||
for p in "${pids[@]}"; do
|
||||
if kill -0 "$p" 2>/dev/null; then
|
||||
kill -KILL "$p" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Purge opcional de los dirs del slot.
|
||||
local purged=false
|
||||
if [[ "$purge" == true ]]; then
|
||||
rm -rf -- /tmp/oo_"${instance}"* 2>/dev/null || true
|
||||
purged=true
|
||||
fi
|
||||
|
||||
# 8. JSON con el array de pids terminados.
|
||||
local pids_json
|
||||
pids_json=$(printf '%s,' "${pids[@]}")
|
||||
pids_json="[${pids_json%,}]"
|
||||
printf '{"instance":"%s","killed_pids":%s,"purged":%s,"status":"closed"}\n' \
|
||||
"$instance" "$pids_json" "$purged"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo o sourceado.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
close_onlyoffice_instance "$@"
|
||||
fi
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: monitor_listening_ports
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "0.3.0"
|
||||
purity: impure
|
||||
signature: "monitor_listening_ports([--interval N], [--once]) -> void"
|
||||
description: "TUI ligera de terminal que refresca cada N segundos una tabla de los sockets TCP en escucha (LISTEN) del equipo local: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO | CMD (cmdline real, util para distinguir python3/node genericos), ordenada por tiempo de vida del proceso dueño (descendente). Una fila por pid. Lanzada como root rellena tambien los sockets de otros usuarios. Modo --once imprime un solo frame y sale."
|
||||
tags: [recon, ports, monitor, tui]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: --interval N
|
||||
desc: "segundos entre refrescos en modo bucle (default: 1, acepta decimales)"
|
||||
- name: --once
|
||||
desc: "imprime un único frame de la tabla y termina con exit 0 (no interactivo; úsalo en tests y en `fn run` para no colgar)"
|
||||
output: "tabla a stdout con columnas IP, PUERTO, PROCESO, PID, TIEMPO ACTIVO ordenada por uptime del proceso descendente; sin --once refresca en bucle infinito hasta Ctrl-C"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/monitor_listening_ports.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Un solo frame (no cuelga) — ideal para fn run o un pipe
|
||||
./fn run monitor_listening_ports_bash_shell --once
|
||||
|
||||
# Como script directo
|
||||
bash bash/functions/shell/monitor_listening_ports.sh --once
|
||||
|
||||
# Sourceada, en bucle interactivo refrescando cada segundo (Ctrl-C para salir)
|
||||
source bash/functions/shell/monitor_listening_ports.sh
|
||||
monitor_listening_ports --interval 1
|
||||
|
||||
# Refresco mas lento
|
||||
monitor_listening_ports --interval 5
|
||||
```
|
||||
|
||||
Salida (frame `--once`, recortado):
|
||||
|
||||
```
|
||||
IP PUERTO PROCESO PID TIEMPO ACTIVO
|
||||
* 8420 registry_api 1885 4d 23:40:46
|
||||
:: 8889 mitmweb 1892 4d 23:40:46
|
||||
127.0.0.1 8484 sqlite_api 1889 4d 23:40:42
|
||||
127.0.0.1 8899 jupyter-lab 155100 4d 19:33:55
|
||||
::1 631 - - ?
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando quieras vigilar **qué puertos abren tus dev-servers / procesos web locales y desde cuándo** llevan vivos, en una sola pantalla que se actualiza sola.
|
||||
- Para detectar de un vistazo un proceso recién levantado (aparece al fondo, con poco TIEMPO ACTIVO) o uno que lleva días escuchando (arriba del todo).
|
||||
- Como paso de reconocimiento local del grupo `recon`: inventario rápido de superficie de escucha TCP del propio equipo, con el dueño de cada socket.
|
||||
- En tests o automatizaciones que solo necesitan un snapshot: añade `--once` para obtener un frame y salir.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: depende de `ss` (paquete iproute2) y `ps` (procps). Si falta cualquiera, sale con exit 1 y un mensaje a stderr.
|
||||
- **Sin sudo no ves PROCESO/PID/CMD de sockets de otros usuarios** (típicamente procesos de root, ej. systemd-resolved en `127.0.0.54:53`, kernels Jupyter de otra sesión, o servidores en contenedores). Esas filas muestran `-`/`?`. La función **no usa sudo** a propósito; para **rellenarlos, lánzala como root**: `pass show claude/sudo | sudo -S bash bash/functions/shell/monitor_listening_ports.sh --interval 1` (el password se pipea, no queda en la cmdline). Como root, `ss` resuelve el dueño de todos los sockets.
|
||||
- **Columna CMD = cmdline real** (`ps -o args=`, recortada a 90 chars). Es lo que distingue un `python3`/`node` genérico (PROCESO) de lo que realmente ejecuta: `python3 -m ipykernel_launcher ...`, `registry_api -port 8420`, etc. Procesos en distinto namespace (docker) pueden seguir sin CMD aunque corras como root.
|
||||
- **Una fila por pid**: un mismo puerto con varios workers (ej. nginx, gunicorn) genera varias filas, una por cada pid dueño del socket.
|
||||
- **`--once` evita colgar**: sin `--once` corre en bucle infinito. No lo lances así en tests ni en `fn run` desatendido — usa `--once`.
|
||||
- **El orden es por uptime del PROCESO, no por el tiempo de la conexión**. `ps -o etimes=` mide cuánto lleva vivo el proceso completo, no cuándo abrió ese socket concreto.
|
||||
- **Carrera ps**: si un pid muere entre `ss` y `ps`, su TIEMPO ACTIVO sale como `?` y la fila se ordena al final (no rompe el bucle; el script usa `set -uo pipefail` sin `-e`).
|
||||
- En modo bucle oculta el cursor (`tput civis`) y lo restaura + limpia en un `trap` EXIT/INT/TERM, de modo que Ctrl-C deja la terminal limpia.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.3.0 (14/06/2026) — añade columna **CMD** con la cmdline real del proceso (mapa pid→args construido en la misma llamada `ps -eo pid=,etimes=,args=`), para distinguir un `python3`/`node` genérico de lo que realmente ejecuta. Documenta cómo rellenar los sockets de otros usuarios (`-`) lanzando la TUI como root. Anchos de columna reajustados para dar sitio a CMD.
|
||||
- v0.2.0 (14/06/2026) — corrige parpadeo y cuelgue del modo bucle. (1) Doble-buffer ANSI: cada frame se computa completo en una variable y se pinta con cursor-home `\033[H` + clear-to-end `\033[J` en vez de `tput clear` antes de recolectar, eliminando el instante en blanco. (2) Rendimiento: una sola llamada a `ps -eo pid=,etimes=` (mapa pid→uptime en memoria, antes era un fork de `ps` por pid) y construcción de filas con `printf -v` (builtin, antes un `$( )` por fila); frame de ~130 ms con cientos de sockets. (3) Bugfix de cuelgue: el avance del parser multi-pid usaba `BASH_REMATCH[0]`, que queda sobrescrito por el `[[ =~ ]]` interno de `_mlp_fmt_etime` → no recortaba el string y entraba en bucle infinito. Ahora el needle se captura justo tras el match, con guard anti-cuelgue si el recorte no progresa.
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bash
|
||||
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
|
||||
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
|
||||
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||
#
|
||||
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
|
||||
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
|
||||
#
|
||||
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
|
||||
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
|
||||
# command substitution por fila: las cadenas se construyen con `printf -v`
|
||||
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
|
||||
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
|
||||
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
|
||||
# entre refrescos.
|
||||
|
||||
# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
|
||||
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
|
||||
set -uo pipefail
|
||||
|
||||
# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
|
||||
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
|
||||
# <1h -> MM:SS ej. 12:45
|
||||
# <1d -> HH:MM:SS ej. 03:12:45
|
||||
# >=1d -> Nd HH:MM:SS ej. 1d 03:12:45
|
||||
_mlp_human=""
|
||||
_mlp_fmt_etime() {
|
||||
local secs="$1"
|
||||
# Si no es un numero entero valido, devolver tal cual (ej. "?").
|
||||
if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
|
||||
_mlp_human="$secs"
|
||||
return 0
|
||||
fi
|
||||
local days=$(( secs / 86400 ))
|
||||
local rem=$(( secs % 86400 ))
|
||||
local hours=$(( rem / 3600 ))
|
||||
local mins=$(( (rem % 3600) / 60 ))
|
||||
local s=$(( rem % 60 ))
|
||||
if (( days > 0 )); then
|
||||
printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
|
||||
elif (( hours > 0 )); then
|
||||
printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
|
||||
else
|
||||
printf -v _mlp_human '%02d:%02d' "$mins" "$s"
|
||||
fi
|
||||
}
|
||||
|
||||
# Imprime un unico frame de la tabla a stdout.
|
||||
# Estrategia de rendimiento (cero forks por fila):
|
||||
# 1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
|
||||
# 2. Un solo `ss -H -tlnp` lista los sockets en escucha.
|
||||
# 3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
|
||||
# (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
|
||||
# fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
|
||||
# O(1) en el mapa.
|
||||
# 4. Se ordena por segundos vivo descendente con un unico `sort`.
|
||||
_mlp_render_frame() {
|
||||
# Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
|
||||
# invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
|
||||
# asi `read` lo captura entero en la tercera variable.
|
||||
local -A etmap=() argmap=()
|
||||
local _pid _et _args
|
||||
while read -r _pid _et _args; do
|
||||
[[ -z "$_pid" ]] && continue
|
||||
etmap["$_pid"]="$_et"
|
||||
argmap["$_pid"]="$_args"
|
||||
done < <(ps -eo pid=,etimes=,args= 2>/dev/null)
|
||||
|
||||
# Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
|
||||
local -a rows=()
|
||||
local line row
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
# Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
|
||||
# Local:Port es el 4o token. Lo extraemos sin fork con read en array.
|
||||
local -a F=()
|
||||
read -ra F <<<"$line"
|
||||
local local_addr="${F[3]:-}"
|
||||
[[ -z "$local_addr" ]] && continue
|
||||
|
||||
# Separar IP y PUERTO partiendo por el ULTIMO ':'.
|
||||
local ip port
|
||||
port="${local_addr##*:}"
|
||||
ip="${local_addr%:*}"
|
||||
# Quitar corchetes de IPv6: [::] -> :: , [::1] -> ::1
|
||||
ip="${ip#[}"
|
||||
ip="${ip%]}"
|
||||
# Caso de bind sin direccion explicita (raro): dejar marcador.
|
||||
[[ -z "$ip" ]] && ip="*"
|
||||
|
||||
# Extraer el bloque users:(...) del final de la linea (si existe).
|
||||
local users=""
|
||||
[[ "$line" == *"users:("* ]] && users="${line#*users:(}"
|
||||
|
||||
if [[ -z "$users" ]]; then
|
||||
# Socket sin info de proceso (pertenece a otro usuario y no corremos
|
||||
# como root). Para verlo, lanzar la TUI como root (ver Gotchas).
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||
rows+=("$row")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
|
||||
# pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
|
||||
local s="$users" pname pid etimes needle prev_s cmd found_any=0
|
||||
while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
|
||||
# IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
|
||||
# comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
|
||||
# cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
|
||||
# despues, contendria el match del ultimo `=~` y el recorte de `s`
|
||||
# no avanzaria -> bucle infinito.
|
||||
pname="${BASH_REMATCH[1]}"
|
||||
pid="${BASH_REMATCH[2]}"
|
||||
needle="${BASH_REMATCH[0]}"
|
||||
found_any=1
|
||||
|
||||
# Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
|
||||
etimes="${etmap[$pid]:-}"
|
||||
if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
|
||||
etimes="-1"
|
||||
_mlp_human="?"
|
||||
else
|
||||
_mlp_fmt_etime "$etimes"
|
||||
fi
|
||||
|
||||
# Comando real (cmdline completa) del pid; dice QUE es realmente un
|
||||
# "python3"/"node" generico. Se recorta para no romper la tabla.
|
||||
cmd="${argmap[$pid]:-}"
|
||||
[[ -z "$cmd" ]] && cmd="-"
|
||||
cmd="${cmd:0:90}"
|
||||
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
|
||||
rows+=("$row")
|
||||
|
||||
# Avanzar mas alla del match actual para no repetir el primer pid.
|
||||
# Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
|
||||
prev_s="$s"
|
||||
s="${s#*"$needle"}"
|
||||
[[ "$s" == "$prev_s" ]] && break
|
||||
done
|
||||
|
||||
# Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
|
||||
if (( found_any == 0 )); then
|
||||
printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
|
||||
rows+=("$row")
|
||||
fi
|
||||
done < <(ss -H -tlnp 2>/dev/null)
|
||||
|
||||
# Estilo de cabecera (negrita) si la terminal lo soporta.
|
||||
local bold="" reset=""
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
|
||||
bold=$(tput bold 2>/dev/null || true)
|
||||
reset=$(tput sgr0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Anchos fijos para alineacion estable (no usamos column -t). La ultima
|
||||
# columna (CMD) es libre: muestra la cmdline real del proceso.
|
||||
local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
|
||||
# shellcheck disable=SC2059
|
||||
printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"
|
||||
|
||||
if (( ${#rows[@]} == 0 )); then
|
||||
printf '(sin sockets TCP en escucha)\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Ordenar por la primera columna (etimes) numerica descendente y emitir las
|
||||
# 5 columnas visibles (descartando la columna de orden).
|
||||
printf '%s\n' "${rows[@]}" \
|
||||
| sort -t$'\t' -k1,1nr \
|
||||
| while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
|
||||
# shellcheck disable=SC2059
|
||||
printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
|
||||
done
|
||||
}
|
||||
|
||||
monitor_listening_ports() {
|
||||
local interval=1
|
||||
local once=0
|
||||
|
||||
# Parseo de flags.
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--interval)
|
||||
interval="${2:-1}"
|
||||
shift 2
|
||||
;;
|
||||
--interval=*)
|
||||
interval="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--once)
|
||||
once=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
monitor_listening_ports [--interval N] [--once]
|
||||
|
||||
--interval N Segundos entre refrescos (default: 1, acepta decimales).
|
||||
--once Imprime un solo frame de la tabla y termina (exit 0).
|
||||
|
||||
Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
|
||||
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
|
||||
USAGE
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Dependencias minimas.
|
||||
if ! command -v ss >/dev/null 2>&1; then
|
||||
printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! command -v ps >/dev/null 2>&1; then
|
||||
printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Modo single-frame: util para tests y para `fn run` sin colgar.
|
||||
if (( once == 1 )); then
|
||||
_mlp_render_frame
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
|
||||
local have_tput=0
|
||||
command -v tput >/dev/null 2>&1 && have_tput=1
|
||||
|
||||
_mlp_cleanup() {
|
||||
if (( have_tput == 1 )); then
|
||||
tput cnorm 2>/dev/null || true # restaurar cursor
|
||||
tput sgr0 2>/dev/null || true # resetear atributos
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT
|
||||
|
||||
(( have_tput == 1 )) && tput civis 2>/dev/null || true # ocultar cursor
|
||||
|
||||
# Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
|
||||
# se computa COMPLETO en una variable y luego se pinta con doble-buffer:
|
||||
# cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
|
||||
# borrar restos de un frame anterior mas largo. Asi nunca hay un instante
|
||||
# con la pantalla vacia mientras se recolectan los datos.
|
||||
printf '\033[2J'
|
||||
|
||||
local frame
|
||||
while true; do
|
||||
frame=$(
|
||||
printf 'monitor_listening_ports — %s — intervalo %ss — orden: TIEMPO ACTIVO desc (Ctrl-C para salir)\n\n' \
|
||||
"$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
|
||||
_mlp_render_frame
|
||||
)
|
||||
printf '\033[H' # cursor al inicio (sin borrar todavia)
|
||||
printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
|
||||
printf '\033[J' # borrar de aqui al final (restos del frame previo)
|
||||
sleep "$interval" || break
|
||||
done
|
||||
}
|
||||
|
||||
# Auto-invocacion cuando se ejecuta como script (no al hacer source).
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
monitor_listening_ports "$@"
|
||||
fi
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: open_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "open_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||
description: "Abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE Desktop Editors (Linux/X11) sin perturbar la instancia personal del usuario. Cada 'instance' (slot, default demo) usa su propio HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR y XDG_CONFIG_HOME bajo /tmp, lo que rompe el single-instance lock de ONLYOFFICE y permite una ventana propia en vez de una pestaña en la instancia del usuario. Espera la ventana por evento (xdotool, basename del archivo, timeout ~25s) sin sleep en foreground. Idempotente: si ya hay ventana para ese basename, no relanza y devuelve el wid existente. NO crea archivos: si file_path no existe, falla. Imprime una linea JSON con instance, file (ruta absoluta), wid (hex), pid y status (open|timeout)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta (relativa o absoluta) al archivo a abrir; DEBE existir, esta funcion no crea archivos. Se normaliza con readlink -f y se busca la ventana por su basename"
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado (default: demo). Determina el env: HOME=/tmp/oo_<instance>, XDG_RUNTIME_DIR=/tmp/oo_<instance>_run, XDG_CONFIG_HOME=/tmp/oo_<instance>/.config. Usa el MISMO instance en reload/close para operar la misma instancia"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"pid\":<n>|null,\"status\":\"open\"|\"timeout\"}. Exit 0 si abrio (status open), exit 1 si la ventana no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta el argumento file_path"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/open_onlyoffice_file.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Como script directo (slot 'demo' por defecto)
|
||||
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/demo_reload.xlsx
|
||||
|
||||
# Slot nombrado distinto (ventana propia, no perturba la instancia personal)
|
||||
bash bash/functions/shell/open_onlyoffice_file.sh /tmp/informe.docx reporte
|
||||
|
||||
# Via fn run
|
||||
./fn run open_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Sourceado, capturando el wid del JSON
|
||||
source bash/functions/shell/open_onlyoffice_file.sh
|
||||
out=$(open_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||
echo "$out"
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid":"0x3c00007","pid":12345,"status":"open"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando necesites **abrir un archivo en ONLYOFFICE Desktop desde terminal en su propia ventana aislada**, sin que se agregue como pestaña a la instancia personal del usuario.
|
||||
- Como primer paso de un flujo automatizado open -> (editas el archivo en disco) -> `reload_onlyoffice_file` -> `close_onlyoffice_instance`.
|
||||
- Cuando quieras un slot reproducible por nombre (`instance`) que reuse la misma instancia aislada entre llamadas (reabrir rapido en vez de arrancar el motor de cero).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ONLYOFFICE Desktop es single-instance por usuario**: sin el slot aislado (HOME/XDG_RUNTIME_DIR propios), un segundo lanzamiento se reenvia a la instancia viva y abre el archivo como PESTAÑA, no ventana nueva. El lock NO se rompe con XDG_CONFIG_HOME solo; SI con HOME + XDG_RUNTIME_DIR propios. Esta funcion ya aplica esa convencion.
|
||||
- **NO hay reload nativo de cambios externos** (GitHub Issue #2313 abierto, no implementado). Esta funcion solo abre; para reflejar ediciones hechas en disco hay que cerrar+reabrir con `reload_onlyoffice_file`.
|
||||
- **NO crea archivos**: si `file_path` no existe, falla con exit 1. Crea el archivo por tu cuenta antes de llamar.
|
||||
- **El slot vive en /tmp**: los dirs `/tmp/oo_<instance>*` se pierden al reiniciar el PC (tmpfs en muchos sistemas). No guardes nada importante ahi; es estado desechable de la instancia aislada.
|
||||
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland (xdotool no encontrara la ventana). La funcion comprueba `command -v` de las 3 deps y falla claro si falta alguna.
|
||||
- **El pid reportado es el del launcher** (`onlyoffice-desktopeditors`), que puede reexec/fork al proceso real `DesktopEditors`; sirve como referencia best-effort, no para `kill` fiable (usa `close_onlyoffice_instance`, que localiza el proceso real por su HOME).
|
||||
- **Idempotencia por basename**: si ya existe una ventana cuyo titulo contiene el basename del archivo (lo abrio el usuario en su instancia personal, por ejemplo), la funcion la considera "ya abierta" y devuelve ese wid sin relanzar. Usa un basename unico para el slot de pruebas si quieres evitar colisiones.
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# open_onlyoffice_file — abre un archivo en una INSTANCIA AISLADA de ONLYOFFICE
|
||||
# Desktop Editors (Linux/X11), sin perturbar la instancia personal del usuario.
|
||||
#
|
||||
# Funcion impura: lanza un proceso GUI, lee estado de ventanas (xdotool) y
|
||||
# escribe directorios en /tmp. Imprime una linea JSON con el resultado.
|
||||
#
|
||||
# Por que "instancia aislada": ONLYOFFICE Desktop es single-instance por
|
||||
# usuario — un segundo `onlyoffice-desktopeditors <file>` se reenvia a la
|
||||
# instancia viva y abre el archivo como PESTAÑA en su ventana. El lock
|
||||
# single-instance NO se rompe con XDG_CONFIG_HOME, pero SI se rompe lanzando
|
||||
# con HOME y XDG_RUNTIME_DIR propios. Por eso cada "slot" nombrado (instance)
|
||||
# usa su propio HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp.
|
||||
|
||||
# Sin -e: las busquedas de ventana (xdotool search) pueden no matchear y
|
||||
# devolver exit !=0; no deben abortar la funcion. -u y pipefail se mantienen.
|
||||
set -uo pipefail
|
||||
|
||||
open_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
if [[ -z "$file_path" ]]; then
|
||||
echo "open_onlyoffice_file: falta <file_path>" >&2
|
||||
echo "uso: open_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# 1. Dependencias del sistema.
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "open_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. El archivo DEBE existir — esta funcion no crea archivos.
|
||||
if [[ ! -f "$file_path" ]]; then
|
||||
echo "open_onlyoffice_file: el archivo no existe: $file_path (esta funcion no crea archivos)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Ruta absoluta y basename para titular/buscar la ventana.
|
||||
local abs_path base
|
||||
abs_path=$(readlink -f -- "$file_path")
|
||||
base=$(basename -- "$abs_path")
|
||||
|
||||
# 3. Slot aislado: HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME propios bajo /tmp.
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
local oo_run="/tmp/oo_${instance}_run"
|
||||
local oo_cfg="${oo_home}/.config"
|
||||
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||
chmod 700 "$oo_run" 2>/dev/null || true
|
||||
|
||||
# 4. Idempotencia: si ya hay ventana para ese basename, no relanzar.
|
||||
local existing_wid
|
||||
existing_wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
if [[ -n "$existing_wid" ]]; then
|
||||
local wid_hex
|
||||
wid_hex=$(printf '0x%x' "$existing_wid" 2>/dev/null || echo "$existing_wid")
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","pid":null,"status":"open"}\n' \
|
||||
"$instance" "$abs_path" "$wid_hex"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 5. Lanzar la instancia aislada con su env propio. setsid lo desacopla de
|
||||
# la terminal; redirige todo a un log del slot.
|
||||
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||
local launch_pid=$!
|
||||
|
||||
# 6. Esperar la ventana por evento (NUNCA sleep en foreground).
|
||||
# ~25s con read -t 0.3 => ~83 iteraciones.
|
||||
local wid="" i=0 max=83
|
||||
while [[ $i -lt $max ]]; do
|
||||
wid=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
[[ -n "$wid" ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [[ -z "$wid" ]]; then
|
||||
printf '{"instance":"%s","file":"%s","wid":null,"pid":%s,"status":"timeout"}\n' \
|
||||
"$instance" "$abs_path" "$launch_pid"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local wid_hex
|
||||
wid_hex=$(printf '0x%x' "$wid" 2>/dev/null || echo "$wid")
|
||||
# El pid del proceso real (DesktopEditors) puede diferir del launcher; el
|
||||
# launcher reexec/fork. Reportamos el pid del launcher (best-effort).
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","pid":%s,"status":"open"}\n' \
|
||||
"$instance" "$abs_path" "$wid_hex" "$launch_pid"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo: `bash open_onlyoffice_file.sh <file> [instance]`.
|
||||
# Sourceado: define la funcion sin ejecutarla.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
open_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: reload_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "reload_onlyoffice_file(file_path: string, instance: string = demo) -> json"
|
||||
description: "Recarga en la ventana de ONLYOFFICE Desktop Editors los datos que el caller edito EN DISCO, cerrando y reabriendo el archivo en la INSTANCIA AISLADA (slot). Es la funcion estrella del grupo: ONLYOFFICE no recarga cambios externos del archivo (GitHub Issue #2313 abierto, no implementado), asi que la unica forma de mostrar datos editados fuera de la app es cerrar la ventana (wmctrl -ic) y reabrir (ONLYOFFICE lee fresco del disco al abrir). Localiza la ventana por basename, la cierra y espera a que desaparezca (timeout ~10s), relanza con el env del slot aislado y espera la ventana nueva (timeout ~25s), todo por evento sin sleep en foreground. Si no habia ventana previa, actua como open. NO edita el archivo: el caller lo edita antes de llamar. Imprime JSON con wid_old, wid_new, reopened, elapsed_s y status (reloaded|timeout)."
|
||||
tags: [onlyoffice, desktop, x11, shell]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta (relativa o absoluta) al archivo cuya ventana se recarga; DEBE existir. El caller ya lo edito en disco antes de llamar. Se busca la ventana por su basename"
|
||||
- name: instance
|
||||
desc: "nombre del slot aislado (default: demo); debe coincidir con el usado en open_onlyoffice_file para reusar la misma instancia. Determina HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME bajo /tmp"
|
||||
output: "una linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid_old\":\"<hex>|null\",\"wid_new\":\"<hex>|null\",\"reopened\":true|false,\"elapsed_s\":<n>,\"status\":\"reloaded\"|\"timeout\"}. Exit 0 si reabrio (status reloaded), exit 1 si la ventana nueva no aparecio en el timeout (status timeout) o falta dependencia/archivo, exit 2 si falta file_path"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/shell/reload_onlyoffice_file.sh"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Flujo tipico: editas el .xlsx en disco con tu herramienta y refrescas la vista
|
||||
# (este ejemplo asume que /tmp/demo_reload.xlsx ya esta abierto en el slot demo)
|
||||
bash bash/functions/shell/reload_onlyoffice_file.sh /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Via fn run
|
||||
./fn run reload_onlyoffice_file_bash_shell /tmp/demo_reload.xlsx demo
|
||||
|
||||
# Sourceado, dentro de un bucle de "editar en disco -> ver en ONLYOFFICE"
|
||||
source bash/functions/shell/reload_onlyoffice_file.sh
|
||||
# ... el caller modifica /tmp/demo_reload.xlsx por su cuenta ...
|
||||
out=$(reload_onlyoffice_file /tmp/demo_reload.xlsx demo)
|
||||
echo "$out"
|
||||
# {"instance":"demo","file":"/tmp/demo_reload.xlsx","wid_old":"0x3c00007","wid_new":"0x3c0000b","reopened":true,"elapsed_s":4,"status":"reloaded"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Cuando **editaste un archivo en disco fuera de ONLYOFFICE** (script, otra herramienta, generador) y necesitas que la ventana de ONLYOFFICE muestre los datos nuevos: esta funcion cierra y reabre para forzar la lectura fresca del disco.
|
||||
- En bucles de iteracion rapida "modificar el archivo -> ver el resultado en ONLYOFFICE" sin tocar la instancia personal del usuario.
|
||||
- Como reemplazo del reload nativo inexistente (Issue #2313): es la unica via fiable de refrescar la vista desde disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **No edita el archivo**: solo recarga la ventana desde disco. El caller es responsable de modificar el archivo ANTES de llamar; si no lo modifico, reabrira los mismos datos.
|
||||
- **ONLYOFFICE no tiene reload de cambios externos** (GitHub Issue #2313 abierto, no implementado): por eso esta funcion existe y hace cerrar+reabrir. No hay forma "in-place" de refrescar.
|
||||
- **`wmctrl -ic` puede disparar el dialogo "Guardar cambios"** si el usuario edito EN la app (no en disco) y hay cambios sin guardar en esa ventana. El flujo previsto es editar SOLO en disco con la ventana sin tocar; si editaste en la app, guarda o descarta antes, o el cierre se quedara esperando interaccion (la funcion saldra por timeout).
|
||||
- **Single-instance + slot aislado**: usa el mismo `instance` que en `open_onlyoffice_file`. Con HOME/XDG_RUNTIME_DIR propios el relaunch reenvia a la instancia aislada viva y reabre rapido; con env por defecto se reenviaria a la instancia personal del usuario (no deseado).
|
||||
- **El slot vive en /tmp**: `/tmp/oo_<instance>*` se pierde al reiniciar el PC. Estado desechable.
|
||||
- **Requiere X11 + wmctrl + xdotool**: no funciona en Wayland puro sin XWayland. Comprueba las 3 deps y falla claro si falta alguna.
|
||||
- **Carrera de cierre**: si la ventana tarda mas de ~10s en cerrarse (dialogo modal, app ocupada), la funcion continua igualmente al relaunch; el resultado puede acabar en `timeout` si la ventana nueva no aparece a tiempo.
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# reload_onlyoffice_file — cierra y reabre un archivo en la INSTANCIA AISLADA de
|
||||
# ONLYOFFICE Desktop Editors para que la ventana muestre los datos editados
|
||||
# EN DISCO por el caller (ONLYOFFICE no recarga cambios externos: GitHub Issue
|
||||
# #2313 abierto, no implementado — la unica forma es cerrar+reabrir).
|
||||
#
|
||||
# Funcion impura: cierra una ventana GUI (wmctrl), relanza un proceso y espera
|
||||
# la ventana nueva por evento. NO edita el archivo — solo recarga la ventana
|
||||
# desde el disco. El caller edita el archivo antes de llamar a esta funcion.
|
||||
#
|
||||
# Instancia aislada (slot): mismo HOME/XDG_RUNTIME_DIR/XDG_CONFIG_HOME que usa
|
||||
# open_onlyoffice_file, para que el relaunch reenvie a la instancia aislada
|
||||
# viva y reabra rapido en vez de arrancar el motor de cero.
|
||||
|
||||
# Sin -e: busquedas de ventana (xdotool/wmctrl) pueden no matchear; no deben
|
||||
# abortar la funcion. -u y pipefail se mantienen.
|
||||
set -uo pipefail
|
||||
|
||||
reload_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
if [[ -z "$file_path" ]]; then
|
||||
echo "reload_onlyoffice_file: falta <file_path>" >&2
|
||||
echo "uso: reload_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# 1. Dependencias del sistema.
|
||||
local dep
|
||||
for dep in onlyoffice-desktopeditors wmctrl xdotool; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "reload_onlyoffice_file: falta dependencia '$dep' (instala el paquete correspondiente)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. El archivo DEBE existir — no editamos ni creamos archivos.
|
||||
if [[ ! -f "$file_path" ]]; then
|
||||
echo "reload_onlyoffice_file: el archivo no existe: $file_path" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local abs_path base
|
||||
abs_path=$(readlink -f -- "$file_path")
|
||||
base=$(basename -- "$abs_path")
|
||||
|
||||
# 3. Slot aislado (identico a open_onlyoffice_file).
|
||||
local oo_home="/tmp/oo_${instance}"
|
||||
local oo_run="/tmp/oo_${instance}_run"
|
||||
local oo_cfg="${oo_home}/.config"
|
||||
mkdir -p "$oo_home" "$oo_cfg" "$oo_run"
|
||||
chmod 700 "$oo_run" 2>/dev/null || true
|
||||
|
||||
local start_ts
|
||||
start_ts=$(date +%s)
|
||||
|
||||
# 4. Localizar la ventana actual del archivo por basename.
|
||||
local wid_old=""
|
||||
wid_old=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
|
||||
local wid_old_hex="null"
|
||||
if [[ -n "$wid_old" ]]; then
|
||||
wid_old_hex=$(printf '0x%x' "$wid_old" 2>/dev/null || echo "$wid_old")
|
||||
|
||||
# 5. Cerrar la ventana (sin teclear en la app) y esperar a que
|
||||
# desaparezca (~10s con read -t 0.3 => ~33 iteraciones).
|
||||
wmctrl -ic "$wid_old" 2>/dev/null || true
|
||||
local g=0 gmax=33
|
||||
while [[ $g -lt $gmax ]]; do
|
||||
if ! xdotool search --name -- "$base" 2>/dev/null | grep -q .; then
|
||||
break
|
||||
fi
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
g=$((g + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
# 6. Relanzar con el env del slot aislado. (Si no habia ventana previa,
|
||||
# esto actua simplemente como open.)
|
||||
env HOME="$oo_home" XDG_RUNTIME_DIR="$oo_run" XDG_CONFIG_HOME="$oo_cfg" \
|
||||
setsid onlyoffice-desktopeditors "$abs_path" \
|
||||
>"/tmp/oo_${instance}.log" 2>&1 </dev/null &
|
||||
|
||||
# 7. Esperar la ventana nueva por evento (~25s => ~83 iteraciones).
|
||||
local wid_new="" i=0 max=83
|
||||
while [[ $i -lt $max ]]; do
|
||||
wid_new=$(xdotool search --name -- "$base" 2>/dev/null | head -1 || true)
|
||||
# Si hubo ventana previa, aceptar cualquier wid que aparezca (el old
|
||||
# ya se cerro; el nuevo puede reutilizar id o no). Si no la hubo,
|
||||
# cualquier wid sirve.
|
||||
[[ -n "$wid_new" ]] && break
|
||||
read -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
local now_ts elapsed
|
||||
now_ts=$(date +%s)
|
||||
elapsed=$((now_ts - start_ts))
|
||||
|
||||
if [[ -z "$wid_new" ]]; then
|
||||
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":null,"reopened":false,"elapsed_s":%s,"status":"timeout"}\n' \
|
||||
"$instance" "$abs_path" "$wid_old_hex" "$elapsed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local wid_new_hex
|
||||
wid_new_hex=$(printf '0x%x' "$wid_new" 2>/dev/null || echo "$wid_new")
|
||||
printf '{"instance":"%s","file":"%s","wid_old":"%s","wid_new":"%s","reopened":true,"elapsed_s":%s,"status":"reloaded"}\n' \
|
||||
"$instance" "$abs_path" "$wid_old_hex" "$wid_new_hex" "$elapsed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo o sourceado.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
reload_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: save_onlyoffice_file
|
||||
kind: function
|
||||
lang: bash
|
||||
domain: shell
|
||||
purity: impure
|
||||
version: 1.1.0
|
||||
description: "Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de OnlyOffice Desktop en Linux/X11 y confirma que llego a disco por cambio de mtime. Primer paso del flujo seguro guardar -> actualizar -> recargar; evita perder cambios no guardados cuando un build regenera el archivo leyendo del disco."
|
||||
signature: "save_onlyoffice_file(file_path: string, [instance: string]) -> json"
|
||||
error_type: error_go_core
|
||||
tags: [onlyoffice, desktop, x11, gui, save, persist]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
file_path: bash/functions/shell/save_onlyoffice_file.sh
|
||||
params:
|
||||
- name: file_path
|
||||
desc: "ruta al documento abierto en OnlyOffice cuyo guardado se quiere forzar. Debe existir. Se normaliza a ruta absoluta y se usa su basename para localizar la ventana."
|
||||
- name: instance
|
||||
desc: "nombre del slot/instancia para etiquetar la salida JSON (default: 'demo'). Usar el MISMO valor que en open/reload/close del mismo documento por coherencia."
|
||||
output: "linea JSON a stdout: {\"instance\":\"<i>\",\"file\":\"<abs>\",\"wid\":\"<hex>|null\",\"status\":\"saved\"|\"no_change\"|\"no_window\",\"dialog_confirmed\":0|1[,\"mtime_before\":N,\"mtime_after\":N]}. dialog_confirmed=1 si se envio Return para cerrar el dialogo modal de formato. Exit 0 salvo error de dependencia o archivo inexistente (exit 1)."
|
||||
---
|
||||
|
||||
Fuerza el guardado (Ctrl+S) de un documento abierto en una instancia de ONLYOFFICE
|
||||
Desktop Editors en Linux/X11 y confirma que el guardado llegó a disco observando el
|
||||
cambio de `mtime` del archivo.
|
||||
|
||||
Existe para cerrar una ventana de pérdida de datos: OnlyOffice mantiene los cambios
|
||||
en memoria hasta que el usuario guarda. Cualquier proceso que regenere el archivo
|
||||
leyendo del disco (un build que refresca hojas, un script de sincronización)
|
||||
perdería el trabajo manual no guardado. Esta función vuelca ese trabajo a disco
|
||||
ANTES de tocar el archivo, de modo que el paso de actualización pueda preservarlo.
|
||||
|
||||
Es el primer paso del flujo seguro de refresco:
|
||||
|
||||
```
|
||||
save_onlyoffice_file -> (actualizar el archivo en disco) -> reload_onlyoffice_file
|
||||
```
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Forzar el guardado de un xlsx abierto en la instancia "afiliados"
|
||||
bash bash/functions/shell/save_onlyoffice_file.sh \
|
||||
/home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||
# {"instance":"afiliados","file":"/home/enmanuel/afiliados/programas_afiliados.xlsx","wid":"0x0a20002a","status":"saved","mtime_before":1718380000,"mtime_after":1718380042}
|
||||
|
||||
# Via fn run (tras fn index)
|
||||
./fn run save_onlyoffice_file /home/enmanuel/afiliados/programas_afiliados.xlsx afiliados
|
||||
|
||||
# Encadenado con la actualización y la recarga (flujo seguro completo)
|
||||
bash bash/functions/shell/save_onlyoffice_file.sh "$XLSX" afiliados
|
||||
python build_xlsx.py # regenera solo las hojas gestionadas
|
||||
./fn run reload_onlyoffice_file "$XLSX" afiliados
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Llámala SIEMPRE justo antes de regenerar o modificar en disco un archivo que el
|
||||
usuario pueda tener abierto en OnlyOffice, para no pisar sus cambios sin guardar.
|
||||
Es el primer eslabón del flujo guardar -> actualizar -> recargar. Si no hay ventana
|
||||
abierta para ese archivo, es un no-op seguro (status `no_window`).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Orden crítico**: guarda ANTES de actualizar el archivo. Si actualizas primero y
|
||||
guardas OnlyOffice después, OnlyOffice sobrescribe tu actualización con su copia
|
||||
en memoria (vieja). El flujo correcto es save -> update -> reload.
|
||||
- **status `no_change`**: el `mtime` no cambió. Normalmente significa que no había
|
||||
cambios pendientes (no es un error).
|
||||
- **Auto-confirmación del diálogo de formato (v1.1.0)**: si tras Ctrl+S el guardado no
|
||||
se completa en ~1.2s, la función asume que OnlyOffice mostró un diálogo modal
|
||||
("mantener formato") y le envía Return, que acepta la opción por defecto (mantener el
|
||||
formato actual). El campo `dialog_confirmed` indica si se envió. Si no había diálogo,
|
||||
el Return va al editor y solo mueve de celda (no altera datos). Para suprimir el
|
||||
diálogo de forma permanente, desmárcalo en OnlyOffice: Configuración avanzada →
|
||||
desactivar el aviso de formato al guardar.
|
||||
- **status `no_window`**: no hay ninguna ventana cuyo título contenga el basename del
|
||||
archivo. No hay nada que guardar; el disco ya es la única fuente de verdad.
|
||||
- **Detección por basename**: dos archivos con el mismo nombre en rutas distintas
|
||||
colisionan al localizar la ventana (igual que open/reload).
|
||||
- **X11 obligatorio**: depende de `xdotool` (y `stat` de coreutils). No funciona en
|
||||
Wayland puro sin XWayland.
|
||||
- **Foco**: la función activa la ventana (`windowactivate --sync`) para que Ctrl+S
|
||||
llegue al editor. Roba el foco un instante; es esperable.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-15) — auto-confirma el diálogo modal "mantener formato" enviando
|
||||
Return a la ventana activa cuando el guardado no se completa en ~1.2s; añade el campo
|
||||
`dialog_confirmed` a la salida JSON.
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# save_onlyoffice_file — fuerza el guardado (Ctrl+S) de un documento abierto en una
|
||||
# instancia de ONLYOFFICE Desktop Editors en Linux/X11 y confirma que el archivo se
|
||||
# escribio a disco observando el cambio de mtime.
|
||||
#
|
||||
# Para que existe: OnlyOffice mantiene los cambios en memoria hasta que el usuario
|
||||
# guarda. Cualquier proceso que regenere el .xlsx leyendo del disco (por ejemplo un
|
||||
# build que refresca hojas) perderia el trabajo manual no guardado. Esta funcion
|
||||
# vuelca ese trabajo a disco ANTES de tocar el archivo, de modo que el paso de
|
||||
# actualizacion pueda preservarlo. Es el primer paso del flujo seguro:
|
||||
# save_onlyoffice_file -> (actualizar el archivo) -> reload_onlyoffice_file
|
||||
#
|
||||
# La ventana se localiza por el basename del archivo (OnlyOffice titula la ventana
|
||||
# "<basename> — ONLYOFFICE"), igual que open_onlyoffice_file. Si no hay ventana
|
||||
# abierta para ese basename no hay nada que guardar: se devuelve status "no_window"
|
||||
# con exit 0 (el disco ya es la unica fuente de verdad).
|
||||
#
|
||||
# Funcion impura: envia eventos de teclado a X11 (xdotool) y lee el estado del
|
||||
# sistema de archivos. Imprime una linea JSON con el resultado a stdout.
|
||||
#
|
||||
# No usamos `set -e`: los pipelines de busqueda de ventanas (xdotool|head) pueden no
|
||||
# matchear y no deben abortar el script. Mantenemos -u y pipefail con guardas.
|
||||
set -uo pipefail
|
||||
|
||||
save_onlyoffice_file() {
|
||||
local file_path="${1:-}"
|
||||
local instance="${2:-demo}"
|
||||
|
||||
# --- 1. Validacion de dependencias del sistema ---
|
||||
local dep
|
||||
for dep in xdotool stat; do
|
||||
if ! command -v "$dep" >/dev/null 2>&1; then
|
||||
echo "error: dependencia ausente: '$dep' (instala xdotool, coreutils)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 2. Validacion de argumentos ---
|
||||
if [ -z "$file_path" ]; then
|
||||
echo "error: uso: save_onlyoffice_file <file_path> [instance]" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$file_path" ]; then
|
||||
echo "error: el archivo no existe: '$file_path'" >&2
|
||||
return 1
|
||||
fi
|
||||
local abs_path
|
||||
abs_path="$(cd "$(dirname "$file_path")" && pwd)/$(basename "$file_path")"
|
||||
local base
|
||||
base="$(basename "$abs_path")"
|
||||
|
||||
# --- 3. Localizar la ventana de OnlyOffice por basename ---
|
||||
local wid=""
|
||||
wid="$(xdotool search --name "$base" 2>/dev/null | head -1 || true)"
|
||||
if [ -z "$wid" ]; then
|
||||
printf '{"instance":"%s","file":"%s","wid":null,"status":"no_window"}\n' \
|
||||
"$instance" "$abs_path"
|
||||
return 0
|
||||
fi
|
||||
local hex
|
||||
hex="$(printf '0x%08x' "$wid" 2>/dev/null || echo "$wid")"
|
||||
|
||||
# --- 4. mtime antes de guardar ---
|
||||
local mtime_before
|
||||
mtime_before="$(stat -c %Y "$abs_path" 2>/dev/null || echo 0)"
|
||||
|
||||
# --- 5. Enfocar la ventana y enviar Ctrl+S ---
|
||||
xdotool windowactivate --sync "$wid" >/dev/null 2>&1 || true
|
||||
xdotool key --clearmodifiers --window "$wid" ctrl+s >/dev/null 2>&1 || true
|
||||
|
||||
# --- 6. Esperar el guardado; auto-confirmar el dialogo de formato si aparece ---
|
||||
# OnlyOffice puede mostrar un dialogo modal ("mantener formato") al guardar. Si el
|
||||
# mtime no cambia en ~1.2s asumimos que hay un modal esperando y le enviamos Return:
|
||||
# acepta la opcion por defecto, que es mantener el formato actual del archivo. Si no
|
||||
# habia dialogo, el Return va al editor y solo mueve de celda (inofensivo: no altera
|
||||
# datos). El intento se repite mientras el guardado no se confirme.
|
||||
local mtime_after="$mtime_before" i=0 confirmed=0
|
||||
local max=27 # ~8s a 0.3s por iteracion
|
||||
until [ "$mtime_after" -gt "$mtime_before" ] || [ "$i" -ge "$max" ]; do
|
||||
read -r -t 0.3 _ </dev/null 2>/dev/null || true
|
||||
mtime_after="$(stat -c %Y "$abs_path" 2>/dev/null || echo "$mtime_before")"
|
||||
i=$((i + 1))
|
||||
# A partir de ~1.2s sin guardar, confirmar el dialogo modal con Return.
|
||||
if [ "$i" -ge 4 ] && [ "$mtime_after" -le "$mtime_before" ]; then
|
||||
local dlg
|
||||
dlg="$(xdotool getactivewindow 2>/dev/null || true)"
|
||||
if [ -n "$dlg" ]; then
|
||||
xdotool key --clearmodifiers --window "$dlg" Return >/dev/null 2>&1 || true
|
||||
confirmed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local status="saved"
|
||||
if [ "$mtime_after" -le "$mtime_before" ]; then
|
||||
# Sin cambio de mtime: no habia nada pendiente que guardar.
|
||||
status="no_change"
|
||||
fi
|
||||
printf '{"instance":"%s","file":"%s","wid":"%s","status":"%s","dialog_confirmed":%s,"mtime_before":%s,"mtime_after":%s}\n' \
|
||||
"$instance" "$abs_path" "$hex" "$status" "$confirmed" "$mtime_before" "$mtime_after"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Ejecutable directo: `bash save_onlyoffice_file.sh <file> [instance]`.
|
||||
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
||||
save_onlyoffice_file "$@"
|
||||
fi
|
||||
@@ -70,6 +70,8 @@ func cmdDoctor(args []string) {
|
||||
doctorDod(r, jsonOut)
|
||||
case "e2e-coverage":
|
||||
doctorE2ECoverage(r, jsonOut)
|
||||
case "projects":
|
||||
doctorProjects(r, jsonOut)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
||||
doctorUsage()
|
||||
@@ -100,6 +102,7 @@ Subcommands:
|
||||
modules Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
|
||||
dod Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
|
||||
e2e-coverage Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
|
||||
projects Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)
|
||||
|
||||
Flags:
|
||||
--json Salida JSON (para scripting/agentes)
|
||||
@@ -505,6 +508,29 @@ func doctorML(root string, jsonOut bool) {
|
||||
fmt.Printf("\nOverall ML environment: %s\n", overall)
|
||||
}
|
||||
|
||||
func doctorProjects(root string, jsonOut bool) {
|
||||
rows, err := infra.AuditProjectsCoverage(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
orphans, oerr := infra.FindOrphanProjectRefs(root)
|
||||
if oerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if jsonOut {
|
||||
emit(map[string]any{
|
||||
"coverage": rows,
|
||||
"orphan_project_ids": orphans,
|
||||
})
|
||||
return
|
||||
}
|
||||
fmt.Print(infra.FormatProjectsCoverage(rows))
|
||||
fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
|
||||
fmt.Print(infra.FormatOrphanProjectRefs(orphans))
|
||||
}
|
||||
|
||||
func emit(v any) {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
|
||||
+74
-6
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -56,8 +58,19 @@ func cmdRun(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
// When fn run executes a scoped `go test -run`, mirror its output into a
|
||||
// buffer so we can detect a "no tests to run" result — which go test reports
|
||||
// with exit 0 and would otherwise be a silent false-green (e.g. the extracted
|
||||
// unit_tests names drifted from the code). See issue 0167.
|
||||
guardGoTest := fn.Lang == "go" && isGoTestRun(cmd)
|
||||
var outBuf bytes.Buffer
|
||||
if guardGoTest {
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf)
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, &outBuf)
|
||||
} else {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[fn run] %s (%s/%s) %s\n", fn.ID, fn.Lang, fn.Kind, strings.Join(passArgs, " "))
|
||||
@@ -66,6 +79,13 @@ func cmdRun(args []string) {
|
||||
runErr := cmd.Run()
|
||||
durationMs := time.Since(t0).Milliseconds()
|
||||
|
||||
// A scoped go test that matched zero tests is a false-green: treat as failure.
|
||||
if guardGoTest && runErr == nil && strings.Contains(outBuf.String(), "no tests to run") {
|
||||
fmt.Fprintf(os.Stderr, "\n[fn run] error: -run no encontro ningun test para %s — los nombres de test extraidos no existen en el codigo; corre 'fn index'\n", fn.ID)
|
||||
logFnRunTelemetry(registryRoot, fn.ID, durationMs, false, "no_tests_run")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
errClass := ""
|
||||
if runErr != nil {
|
||||
@@ -140,7 +160,7 @@ func resolveFunction(db *registry.DB, idOrName string) (*registry.Function, erro
|
||||
func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
||||
switch fn.Lang {
|
||||
case "go":
|
||||
return buildGoCommand(fn, registryRoot, absPath, args)
|
||||
return buildGoCommand(fn, db, registryRoot, absPath, args)
|
||||
case "py":
|
||||
return buildPyRunnerCommand(fn, db, registryRoot, args)
|
||||
case "bash":
|
||||
@@ -154,7 +174,7 @@ func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath
|
||||
}
|
||||
}
|
||||
|
||||
func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
||||
func buildGoCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
||||
dir := filepath.Dir(absPath)
|
||||
env := append(os.Environ(), "CGO_ENABLED=1")
|
||||
|
||||
@@ -168,13 +188,23 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// Library code: if it has tests → go test
|
||||
// Library code with tests → go test, but scoped to THIS function's tests via
|
||||
// -run, so a flaky test of a sibling function in the same package does not
|
||||
// break `fn run`. Test names come from the indexer-extracted unit_tests table
|
||||
// (parsed from the real .go, reliable), never the .md frontmatter (can drift).
|
||||
// The cmdRun guard fails the run if -run matches zero tests, preventing a
|
||||
// silent "no tests to run" false-green. See issue 0167.
|
||||
if fn.Tested && fn.TestFilePath != "" {
|
||||
testAbs := filepath.Join(registryRoot, fn.TestFilePath)
|
||||
if _, err := os.Stat(testAbs); err == nil {
|
||||
relPkg, _ := filepath.Rel(registryRoot, dir)
|
||||
pkgPath := "./" + filepath.ToSlash(relPkg)
|
||||
cmdArgs := append([]string{"test", "-v", "-count=1", "-tags", "fts5", pkgPath}, args...)
|
||||
cmdArgs := []string{"test", "-v", "-count=1", "-tags", "fts5"}
|
||||
if names := goTestNames(db, fn.ID); len(names) > 0 {
|
||||
cmdArgs = append(cmdArgs, "-run", "^("+strings.Join(names, "|")+")$")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, pkgPath)
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
cmd := exec.Command("go", cmdArgs...)
|
||||
cmd.Dir = registryRoot
|
||||
cmd.Env = env
|
||||
@@ -193,6 +223,44 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// goTestNames returns the top-level Go test function names registered for fn in
|
||||
// the indexer-extracted unit_tests table. These drive `go test -run` so that
|
||||
// `fn run` only executes the function's own tests, isolating it from flaky tests
|
||||
// of sibling functions in the same package. Returns nil if none are known (db is
|
||||
// nil, lookup fails, or no tests extracted), in which case the caller falls back
|
||||
// to running the whole package.
|
||||
func goTestNames(db *registry.DB, functionID string) []string {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
uts, err := db.GetUnitTestsByFunction(functionID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, ut := range uts {
|
||||
if ut.Name != "" {
|
||||
names = append(names, ut.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// isGoTestRun reports whether cmd is a `go test ... -run ...` invocation, used to
|
||||
// enable the zero-tests-matched guard in cmdRun.
|
||||
func isGoTestRun(cmd *exec.Cmd) bool {
|
||||
var hasTest, hasRun bool
|
||||
for _, a := range cmd.Args {
|
||||
switch a {
|
||||
case "test":
|
||||
hasTest = true
|
||||
case "-run":
|
||||
hasRun = true
|
||||
}
|
||||
}
|
||||
return hasTest && hasRun
|
||||
}
|
||||
|
||||
|
||||
func buildBashCommand(absPath string, args []string) (*exec.Cmd, error) {
|
||||
cmdArgs := append([]string{absPath}, args...)
|
||||
|
||||
@@ -169,6 +169,39 @@ Para diagnosticar un diff: revisar el PNG actual en
|
||||
`cpp/build/tests/visual_actual/<demo>.png` vs el golden en
|
||||
`cpp/tests/golden/<demo>.png`.
|
||||
|
||||
### Tests de UI headless (Dear ImGui Test Engine)
|
||||
|
||||
`fn::run_app_test` (el harness del Test Engine usado por `/e2e-cpp`) crea la
|
||||
ventana GLFW **oculta por defecto** (`GLFW_VISIBLE=FALSE`). El contexto OpenGL
|
||||
real se crea igual, así que el render que el Test Engine ejercita sigue siendo
|
||||
fiel, pero la ventana nunca se mapea en pantalla: cero parpadeo y no roba foco
|
||||
mientras corre la suite. Es el comportamiento preferente para tests de
|
||||
frontend en C++.
|
||||
|
||||
Control del modo (en orden de prioridad):
|
||||
|
||||
| Mecanismo | Efecto |
|
||||
|---|---|
|
||||
| `FN_HEADLESS=0` (env) | Fuerza ventana **visible** — para depurar un test a ojo. |
|
||||
| `FN_HEADLESS=1` (env) | Fuerza oculta (es el default del path de test). |
|
||||
| `cfg.headless = true` | Oculta también `fn::run_app` (apps reales, p.ej. smoke/capture). |
|
||||
| sin nada | `run_app_test` → oculta; `run_app` → visible. |
|
||||
|
||||
Cómo correr la suite sin parpadeo:
|
||||
|
||||
```bash
|
||||
# Host con GL nativo (GPU real): binario directo, ventana oculta, sin parpadeo.
|
||||
./build/linux_tests/apps/<app>/<app>_tests
|
||||
|
||||
# CI / WSL sin display: display virtual en RAM (también headless).
|
||||
xvfb-run -a -s "-screen 0 1280x800x24" \
|
||||
env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \
|
||||
./build/linux_tests/apps/<app>/<app>_tests
|
||||
|
||||
# Ver un test a ojo (desactiva headless):
|
||||
FN_HEADLESS=0 ./build/linux_tests/apps/<app>/<app>_tests
|
||||
```
|
||||
|
||||
### CI gate `check_tested.sh`
|
||||
|
||||
`cpp/scripts/check_tested.sh [days]` (default `30`) consulta `registry.db` y
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
@@ -647,6 +648,25 @@ static void draw_header_badge_on_floating_panels(const AppConfig& cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resuelve si la ventana GLFW debe crearse oculta (GLFW_VISIBLE=FALSE).
|
||||
// default_hidden : politica base del path de entrada (apps reales = false,
|
||||
// tests de UI = true).
|
||||
// config_headless: AppConfig.headless explicito de la app.
|
||||
// El entorno FN_HEADLESS gana sobre ambos: "0"/"false" fuerza visible,
|
||||
// cualquier otro valor no vacio fuerza oculta. Sin la variable, se respeta
|
||||
// default_hidden || config_headless.
|
||||
static bool resolve_headless(bool default_hidden, bool config_headless) {
|
||||
bool hidden = default_hidden || config_headless;
|
||||
if (const char* e = std::getenv("FN_HEADLESS")) {
|
||||
if (std::strcmp(e, "0") == 0 || std::strcmp(e, "false") == 0) {
|
||||
hidden = false;
|
||||
} else if (e[0] != '\0') {
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
int run_app(AppConfig config, std::function<void()> render_fn) {
|
||||
// Logger primero para capturar fallos del propio init (GLFW, ventana, GL).
|
||||
if (config.log.file_path != nullptr) {
|
||||
@@ -672,6 +692,11 @@ int run_app(AppConfig config, std::function<void()> render_fn) {
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
|
||||
|
||||
// Apps reales: ventana visible por defecto. Solo se oculta si la app pide
|
||||
// headless o el entorno FN_HEADLESS lo fuerza (smoke/capture sin parpadeo).
|
||||
const bool hidden = resolve_headless(/*default_hidden=*/false, config.headless);
|
||||
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
|
||||
|
||||
GLFWwindow* window = glfwCreateWindow(config.width, config.height, config.title, nullptr, nullptr);
|
||||
if (!window) {
|
||||
fprintf(stderr, "Failed to create GLFW window\n");
|
||||
@@ -1178,6 +1203,13 @@ int run_app_test(AppConfig config,
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
|
||||
|
||||
// Tests de frontend: ventana OCULTA por defecto (headless) para no parpadear
|
||||
// en la pantalla del desarrollador ni robar foco mientras el Test Engine
|
||||
// ejercita la UI. El contexto GL real se crea igual, asi que el render sigue
|
||||
// siendo fiel. Opt-out para depurar visualmente: FN_HEADLESS=0.
|
||||
const bool hidden = resolve_headless(/*default_hidden=*/true, config.headless);
|
||||
glfwWindowHint(GLFW_VISIBLE, hidden ? GLFW_FALSE : GLFW_TRUE);
|
||||
|
||||
GLFWwindow* window = glfwCreateWindow(
|
||||
config.width, config.height,
|
||||
config.title ? config.title : "fn_test", nullptr, nullptr);
|
||||
|
||||
@@ -101,6 +101,21 @@ struct AppConfig {
|
||||
int height = 720;
|
||||
bool vsync = true;
|
||||
bool viewports = true; // Multi-viewport ON por defecto: ventanas ImGui arrastrables fuera del main window
|
||||
|
||||
// Headless: si true, la ventana GLFW se crea oculta (GLFW_VISIBLE=FALSE).
|
||||
// El contexto OpenGL real se sigue creando y el render ocurre offscreen,
|
||||
// por lo que las pruebas visuales y de UI siguen siendo fieles, pero la
|
||||
// ventana nunca se mapea en pantalla (cero parpadeo, no roba foco).
|
||||
//
|
||||
// Politica por path:
|
||||
// - run_app (apps reales): default visible (headless = false).
|
||||
// - run_app_test (Dear ImGui Test Engine): default OCULTA. Los tests de
|
||||
// frontend corren headless salvo opt-out explicito para debug visual.
|
||||
//
|
||||
// Override por entorno (gana sobre el default del path y sobre este flag):
|
||||
// FN_HEADLESS=1 / true -> fuerza ventana oculta.
|
||||
// FN_HEADLESS=0 / false -> fuerza ventana visible (ej. ver un test).
|
||||
bool headless = false;
|
||||
ThemeMode theme = ThemeMode::FnDark; // Identidad visual unificada por defecto
|
||||
float bg_r = 0.102f; // fn_tokens::colors::bg (dark.7 #1A1B1E)
|
||||
float bg_g = 0.106f;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,199 @@
|
||||
---
|
||||
id: "0171"
|
||||
title: "Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea"
|
||||
status: pendiente
|
||||
type: enhancement
|
||||
domain:
|
||||
- registry-quality
|
||||
- infra
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0166"]
|
||||
created: 2026-06-10
|
||||
updated: 2026-06-10
|
||||
tags: [projects, subrepo, gitea, clone, backup, manifest, fn-doctor]
|
||||
---
|
||||
|
||||
> **Actualización 10/06/2026 — implementado el núcleo (enfoque KISS).** El manifest
|
||||
> `subrepos.yaml` propuesto abajo se **descartó**: `registry.db` (tablas `apps`/`analysis`
|
||||
> con `project_id`, propagadas entre PCs por `fn sync`) **ya es** el manifest de sub-repos, y
|
||||
> `clone_project_subrepos_bash_pipelines` ya lo consume. No hace falta un archivo nuevo. Lo que
|
||||
> faltaba era integración + auditoría. Ver `## Estado de implementación` al final.
|
||||
# 0171 — Manifest de sub-repos por project + re-clonado y auditoría de cobertura en Gitea
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0171 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta (riesgo de pérdida de datos) |
|
||||
| **Tipo** | enhancement — metadata de projects + `/full-git-pull` + `fn doctor` |
|
||||
|
||||
## Contexto
|
||||
|
||||
El 10/06/2026, al preparar un dashboard sobre el project `aurgi`, se descubrió que el project
|
||||
paraguas **no existía en Gitea** (`dataforge/aurgi` → 404). Sus 3 analyses sí estaban a salvo como
|
||||
sub-repos independientes (`dataforge/venta_web`, `dataforge/sale_prices_comprobation`,
|
||||
`dataforge/presupuestos_callcenter`), pero **el `project.md`, `vault.yaml` y `CONVENTIONS.md` de
|
||||
nivel-project no estaban versionados en ningún sitio**. Reconstruir el project obligó a *adivinar*
|
||||
los nombres de los sub-repos hijos uno a uno desde la lista completa de repos de Gitea.
|
||||
|
||||
Una auditoría de cobertura `projects ↔ Gitea` confirmó el agujero:
|
||||
|
||||
| Project | Repo Gitea | Riesgo |
|
||||
|---|---|---|
|
||||
| fleet_monitoring, fn_monitoring, message_bus, web_scraping | ✅ | ninguno |
|
||||
| **obsidian**, **osint** | ❌ (solo en disco local) | alto — resuelto en esta sesión (subidos a `dataforge/obsidian`, `dataforge/osint`) |
|
||||
| **aurgi** | ❌ (404, paraguas inexistente) | pendiente — analyses salvados, docs nivel-project no |
|
||||
|
||||
Dos problemas estructurales quedan abiertos:
|
||||
|
||||
1. **Projects sin repo Gitea**: su contenido de nivel-project vive solo en disco. Si se borra el
|
||||
disco (o el project no se sincroniza a otro PC), se pierde. La regla `projects.md` dice que cada
|
||||
project debe ser su propio repo Gitea, pero no hay nada que lo **verifique ni lo fuerce**.
|
||||
|
||||
2. **Sub-repos hijos no referenciados**: el `.gitignore` de cada project excluye `apps/*/` y
|
||||
`analysis/*/` (son sub-repos independientes). Por tanto, **un clon fresco del project NO trae sus
|
||||
hijos**, y no existe ningún manifest que diga *qué hijos clonar*. Hoy `/full-git-pull` solo
|
||||
descubre repos vía `discover_git_repos_bash_infra` (busca `.git` ya presentes en disco): si el
|
||||
hijo nunca se clonó, es invisible. Resultado: para reconstruir un project en una máquina nueva hay
|
||||
que adivinar sus sub-repos (exactamente lo que pasó con aurgi).
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que **todo project** (a) tenga su repo Gitea garantizado y (b) **referencie declarativamente sus
|
||||
sub-repos hijos** (apps + analyses), de modo que clonar el project en cualquier PC permita
|
||||
re-clonar automáticamente todo su árbol sin adivinar nada.
|
||||
|
||||
## Propuesta
|
||||
|
||||
### 1. Manifest de sub-repos por project
|
||||
|
||||
Añadir a cada project un manifest declarativo de sus hijos. Dos opciones de formato (decidir una):
|
||||
|
||||
- **Opción A (KISS, preferida): `subrepos.yaml`** en la raíz del project, análogo a `vault.yaml`:
|
||||
|
||||
```yaml
|
||||
# projects/<p>/subrepos.yaml — sub-repos hijos de este project (apps + analyses)
|
||||
subrepos:
|
||||
- kind: analysis # app | analysis
|
||||
name: venta_web
|
||||
path: analysis/venta_web
|
||||
repo: dataforge/venta_web
|
||||
url: https://gitea-.../dataforge/venta_web
|
||||
- kind: analysis
|
||||
name: sale_prices_comprobation
|
||||
path: analysis/sale_prices_comprobation
|
||||
repo: dataforge/sale_prices_comprobation
|
||||
url: https://gitea-.../dataforge/sale_prices_comprobation
|
||||
```
|
||||
|
||||
- **Opción B: sección `## Sub-repos`** en `project.md` con una tabla `kind | name | path | url`.
|
||||
|
||||
`subrepos.yaml` (Opción A) es más fácil de parsear por las funciones de git y se versiona con el
|
||||
project (no está en el `.gitignore`). El manifest se **autogenera/actualiza** escaneando los `.git`
|
||||
hijos presentes en disco + su `remote get-url origin` (reusar `discover_git_repos_bash_infra`).
|
||||
|
||||
### 2. Generación y mantenimiento del manifest
|
||||
|
||||
Función/pipeline nueva (delegar a `fn-constructor`, grupo `infra`/git) que, dado un project:
|
||||
- Escanea `apps/*/.git` y `analysis/*/.git`, lee su remote origin.
|
||||
- Escribe/actualiza `subrepos.yaml`.
|
||||
- Idempotente. Se invoca dentro de `/full-git-push` (o `fn index`) para mantener el manifest al día.
|
||||
|
||||
### 3. Re-clonado desde el manifest en `/full-git-pull`
|
||||
|
||||
Extender `/full-git-pull` para que, tras actualizar cada project, lea su `subrepos.yaml` y **clone
|
||||
los hijos que falten** (`url` → `path`). Así, en un PC nuevo: clonar `dataforge/<project>` →
|
||||
`/full-git-pull` → reconstruye apps + analyses automáticamente. Requiere una función
|
||||
`clone_missing_subrepos_bash_infra(project_dir)` (delegar a `fn-constructor`).
|
||||
|
||||
### 4. Garantizar repo Gitea de cada project + auditoría en `fn doctor`
|
||||
|
||||
- Subcomando nuevo `fn doctor projects` (función `audit_projects_coverage_go_infra`): por cada
|
||||
project en disco reporta `repo_gitea` (existe en Gitea sí/no), `repo_url` (declarado en project.md
|
||||
sí/no), y `subrepos_manifest` (presente + cuántos hijos en disco sin entrada / en manifest sin
|
||||
clonar). Salida `--json`. Cero hallazgos = sano.
|
||||
- Acción derivada documentada: `repo_gitea=no` → `ensure_repo_synced_bash_infra projects/<p>
|
||||
dataforge <p> master "init: project <p>"`.
|
||||
|
||||
### 5. Backfill inicial
|
||||
|
||||
- `aurgi`: traer su `project.md` / `vault.yaml` / `CONVENTIONS.md` de `aurgi-pc` (o `home-wsl`) y
|
||||
crear `dataforge/aurgi` + `subrepos.yaml` con los 3 analyses ya conocidos. **No** reconstruir a
|
||||
mano un `project.md` mínimo (divergiría del real).
|
||||
- Resto de projects con hijos (`fleet_monitoring`, `fn_monitoring`, `message_bus`, `web_scraping`):
|
||||
generar su `subrepos.yaml` con la función del punto 2.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: clon fresco reconstruye árbol | e2e | clonar `dataforge/<p>` en dir limpio → `/full-git-pull` | apps + analyses del project re-clonados desde `subrepos.yaml` |
|
||||
| Edge: project sin hijos (obsidian) | e2e | generar manifest | `subrepos.yaml` válido y vacío (o ausente), sin error |
|
||||
| Edge: hijo en disco sin `.git` | unit | auditoría | `fn doctor projects` lo reporta como "hijo sin sub-repo" |
|
||||
| Error: project sin repo Gitea | e2e | `fn doctor projects --json` | lo marca `repo_gitea=false`, sugiere `ensure_repo_synced` |
|
||||
| Cobertura | audit | `fn doctor projects` | 0 projects sin repo, 0 hijos sin referenciar |
|
||||
|
||||
## Decisiones abiertas
|
||||
|
||||
1. **Formato del manifest**: `subrepos.yaml` (A) vs. sección en `project.md` (B). Recomendado A.
|
||||
2. **¿Auto-generar el manifest en `fn index`** o solo en `/full-git-push`? (evitar I/O de red en
|
||||
`fn index`; preferible en push).
|
||||
3. **aurgi**: ¿traer de `aurgi-pc` por SSH ahora, o dejarlo para cuando el project se sincronice?
|
||||
|
||||
## Notas
|
||||
|
||||
En esta sesión ya se resolvió el riesgo inmediato: `obsidian` y `osint` se subieron a Gitea
|
||||
(`dataforge/obsidian`, `dataforge/osint`) con `ensure_repo_synced_bash_infra` y se les añadió
|
||||
`repo_url` en su `project.md`. Este issue cubre la solución **estructural y reutilizable** para que
|
||||
el caso no vuelva a ocurrir con ningún project. Relacionado con #0166 (dependencias app→app para
|
||||
build reproducible): ambos persiguen que clonar el ecosistema en un PC nuevo sea determinista.
|
||||
|
||||
## Estado de implementación (10/06/2026)
|
||||
|
||||
Implementado con enfoque KISS, **sin** `subrepos.yaml` (registry.db + `fn sync` ya cumplen esa
|
||||
función). Cambios:
|
||||
|
||||
**Funciones nuevas:**
|
||||
- `ensure_project_gitignore_bash_infra` — garantiza idempotente el `.gitignore` canónico de un
|
||||
project (`apps/*/`, `analysis/*/`, `vaults/*` + excepciones) antes de cualquier `git add -A`,
|
||||
para no trackear el contenido de los sub-repos hijos.
|
||||
- `audit_projects_coverage_go_infra` (+ `FormatProjectsCoverage`) — motor de `fn doctor projects`.
|
||||
Reporta por project: `git`/`remote`/`repo_url`/`children (cloned/inDB)` + issues
|
||||
(`no_gitea_repo`, `children_missing`, `dir_not_found`). Solo git local + registry.db, sin red.
|
||||
|
||||
**Integraciones:**
|
||||
- `full_git_push` v1.1.0 — paso 1c: auto-inicializa y pushea los **projects paraguas** sin repo
|
||||
(antes solo apps/analyses), asegurando el `.gitignore` canónico primero. Cierra el agujero
|
||||
aurgi/obsidian/osint.
|
||||
- `full_git_pull` v1.1.0 — paso 6: tras `fn sync`, reclona los sub-repos hijos faltantes de cada
|
||||
project con `clone_project_subrepos` + re-index. Clonar el paraguas + `/full-git-pull` reconstruye
|
||||
el árbol entero.
|
||||
- `fn doctor projects` — nuevo subcomando (`cmd/fn/doctor.go`). Hoy reporta **0 projects con
|
||||
problemas**.
|
||||
|
||||
**Hecho aparte (riesgo inmediato):** `dataforge/obsidian` + `dataforge/osint` creados, `repo_url`
|
||||
en sus `project.md`.
|
||||
|
||||
### Pendientes (no bloquean el núcleo)
|
||||
|
||||
1. **Check inverso — HECHO (10/06/2026).** `FindOrphanProjectRefs` + `FormatOrphanProjectRefs` en
|
||||
`audit_projects_coverage_go_infra`, enchufado en `fn doctor projects`. Detecta apps/analysis con
|
||||
`project_id` sin fila en `projects`. Hoy reporta 4 paraguas huérfanos (existen en otro PC, nunca
|
||||
subidos a Gitea — mismo caso que aurgi):
|
||||
- `element_agents` (6 apps: agents_and_robots, agents_dashboard, device_agent, element_matrix_chat,
|
||||
matrix_admin_panel, matrix_client_pc)
|
||||
- `imagegen` (image_to_3d_studio)
|
||||
- `osint_graph` (graph_explorer)
|
||||
- `aurgi` (sus analyses sí están en Gitea; el paraguas no)
|
||||
2. **Fix de datos de los 4 paraguas huérfanos — pendiente, requiere el PC origen.** No están en disco
|
||||
ni en Gitea en este PC (`lucas-linux`), así que no se pueden reconstruir aquí sin inventar. El fix
|
||||
correcto: correr `/full-git-push` en el PC donde cada paraguas existe en disco (`aurgi-pc` /
|
||||
`home-wsl`). Con `full_git_push` v1.1.0 (paso 1c) eso ya los crea en Gitea automáticamente. Tras
|
||||
eso, `/full-git-pull` aquí (paso 6) los traerá. NO reconstruir un `project.md` mínimo a mano.
|
||||
3. **DoD vida útil**: validar el reclonado en un PC nuevo real (clon limpio del paraguas →
|
||||
`/full-git-pull` → árbol reconstruido) antes de declarar el issue cerrado.
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
id: "0172"
|
||||
title: "App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes sobre el vault osint"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- osint
|
||||
- frontend
|
||||
scope: app-scoped
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0171"]
|
||||
created: 2026-06-10
|
||||
updated: 2026-06-10
|
||||
tags: [osint, web, sigma, graph, mantine, obsidian, vault, dashboard]
|
||||
---
|
||||
# 0172 — App web OSINT: grafo sigma.js + tablas por tipo + fichas con imágenes
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0172 |
|
||||
| **Estado** | pendiente (solo plan — se construye cuando el vault tenga más datos) |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | app — nueva app web en `projects/osint/apps/osint_web` |
|
||||
| **Project** | osint (`projects/osint/`) |
|
||||
|
||||
## Contexto
|
||||
|
||||
El project `osint` guarda sus investigaciones en el vault de Obsidian
|
||||
`/home/enmanuel/Obsidian/osint` (sub-repo `dataforge/osint`). Hoy ese vault tiene:
|
||||
|
||||
- **~82 nodos** repartidos en carpetas tipadas: `personas/` (45), `organizaciones/` (25),
|
||||
`lugares/` (10), `dominios/` (1), `casos/` (1).
|
||||
- **Datos tabulares** en el frontmatter YAML de cada ficha: `tipo`, `nombre`, `sexo`,
|
||||
`fecha_nacimiento`, `dni`, `direccion`, `pais`, `aliases`, `tags`, etc.
|
||||
- **Aristas implícitas**: los wikilinks `[[...]]` en las secciones `Relaciones`, `Lugares` y
|
||||
`Documentos` conectan unas fichas con otras (y con sus attachments).
|
||||
- **~240 attachments**: fotos, DNIs, certificados y PDFs en `attachments/<tipo>/<slug>/`,
|
||||
embebidos en las notas con `![[...]]`.
|
||||
|
||||
Obsidian es bueno para *escribir* la investigación, pero malo para *explorarla* de un vistazo:
|
||||
no da un grafo navegable de todos los objetivos, ni una tabla filtrable, ni una ficha-resumen
|
||||
con la galería de imágenes de cada persona. Metabase/Grafana no encajan: leen BD SQL (no `.md`),
|
||||
y no muestran ni grafo de nodos ni imágenes inline.
|
||||
|
||||
Decisión del usuario (10/06/2026): construir una **app web propia** que lea el vault y ofrezca
|
||||
tres vistas — **grafo explorable con sigma.js**, **tablas filtradas por tipo**, y **fichas con
|
||||
imágenes**. Este issue es **solo el plan**: la recopilación de datos en Obsidian continúa primero;
|
||||
la app se implementa cuando haya suficiente material que justifique la inversión.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Una app web local que, leyendo directamente los `.md` del vault `osint` (sin BD intermedia
|
||||
obligatoria en v1), permita:
|
||||
|
||||
1. **Explorar el grafo** de nodos (personas, organizaciones, lugares, dominios, casos) y sus
|
||||
conexiones por wikilinks, con sigma.js: zoom, pan, click en nodo → ficha, colores por tipo,
|
||||
filtro de tipos visibles, búsqueda de nodo.
|
||||
2. **Ver tablas filtradas por tipo**: una tabla por categoría (personas, organizaciones, ...)
|
||||
con las columnas del frontmatter, ordenable y filtrable (por dni, lugar, fecha, tag).
|
||||
3. **Abrir la ficha** de cualquier nodo: frontmatter renderizado + cuerpo Markdown + galería de
|
||||
sus attachments (fotos, DNIs, PDFs) servidos por el backend.
|
||||
|
||||
## Arquitectura propuesta
|
||||
|
||||
```
|
||||
projects/osint/apps/osint_web/ (sub-repo Gitea dataforge/osint_web)
|
||||
app.md frontmatter de registro (framework: react-vite-mantine)
|
||||
server/ backend Python (lee el vault, sirve JSON + attachments)
|
||||
main.py FastAPI o stdlib http
|
||||
frontend/ React + Vite + Mantine + sigma.js
|
||||
src/
|
||||
views/GraphView.tsx sigma.js + graphology
|
||||
views/TablesView.tsx Mantine DataTable filtrable por tipo
|
||||
views/NodeCard.tsx ficha + galería de attachments
|
||||
```
|
||||
|
||||
### Backend (Python — máximo reuso del grupo `obsidian`)
|
||||
|
||||
Python porque el grupo de capacidad `obsidian` (11 funciones, dominio `obsidian`) ya cubre casi
|
||||
todo el parseo del vault. **Registry-first**: el backend orquesta estas funciones, no reimplementa
|
||||
el parseo.
|
||||
|
||||
Funciones del registry a reutilizar:
|
||||
|
||||
| Función | Uso en la app |
|
||||
|---|---|
|
||||
| `list_obsidian_notes_py_obsidian` | enumerar nodos por carpeta/tipo |
|
||||
| `read_obsidian_note_py_obsidian` | leer ficha: `{frontmatter, body, wikilinks, tags}` |
|
||||
| `parse_obsidian_frontmatter_py_obsidian` | datos tabulares de cada nodo |
|
||||
| `extract_obsidian_wikilinks_py_obsidian` | aristas del grafo |
|
||||
| `extract_obsidian_embeds_py_obsidian` | attachments embebidos en cada nota |
|
||||
| `resolve_obsidian_embed_py_obsidian` | resolver `![[foto.jpg]]` → path real en disco para servir la imagen |
|
||||
| `slugify_obsidian_name_py_obsidian` | normalizar nombre de wikilink → id de nodo |
|
||||
| `search_obsidian_notes_py_obsidian` | búsqueda global en el grafo |
|
||||
|
||||
Funciones **nuevas** a delegar a `fn-constructor` (no escribir inline en la app):
|
||||
|
||||
- `build_obsidian_graph_py_obsidian` (impure) — dado `vault_dir`, devuelve
|
||||
`{"nodes": [{id, tipo, label, frontmatter}], "edges": [{source, target, kind}]}`.
|
||||
Resuelve cada wikilink a un nodo existente (vía slug / nombre de archivo); los wikilinks que
|
||||
no resuelven a un `.md` del vault se marcan como aristas "dangling" o se descartan según flag.
|
||||
Tag de grupo: `obsidian`. Es la pieza que el grupo declara como frontera no cubierta
|
||||
("No indexa el grafo agregado") — esta función la cierra.
|
||||
|
||||
Endpoints HTTP (JSON salvo el de attachments):
|
||||
|
||||
| Método | Ruta | Devuelve |
|
||||
|---|---|---|
|
||||
| GET | `/api/graph` | grafo completo `{nodes, edges}` para sigma.js |
|
||||
| GET | `/api/nodes?tipo=persona` | filas de la tabla de ese tipo (frontmatter aplanado) |
|
||||
| GET | `/api/node/{slug}` | ficha: frontmatter + body (HTML/markdown) + lista de attachments |
|
||||
| GET | `/api/attachment?path=...` | sirve el binario del attachment (image/pdf), con allowlist al vault |
|
||||
| GET | `/api/search?q=...` | nodos que matchean |
|
||||
|
||||
Seguridad: el backend solo sirve archivos **dentro** del vault osint (path traversal bloqueado).
|
||||
El vault contiene datos personales sensibles (DNIs) → la app escucha **solo en `127.0.0.1`**, sin
|
||||
exponer a red. No es un service desplegable a VPS.
|
||||
|
||||
### Frontend (React + Vite + Mantine + sigma.js)
|
||||
|
||||
- Sistema del registry: React + Vite + Mantine v9 + `@fn_library` (grupo `mantine`, 63 funciones).
|
||||
Componentes propios de `@fn_library` antes que HTML nativo (regla `frontend_theming.md`).
|
||||
- **Grafo**: `sigma.js` + `graphology`. Color por `tipo`, tamaño por grado, layout
|
||||
force-directed (graphology-layout-forceatlas2). Click en nodo → abre `NodeCard`. Panel lateral
|
||||
con toggles de tipos visibles y caja de búsqueda.
|
||||
- **Tablas**: una pestaña por tipo, Mantine `Table`/DataTable con columnas del frontmatter,
|
||||
orden y filtro por columna (dni, lugar, fecha_nacimiento, tags).
|
||||
- **Fichas**: `NodeCard` con frontmatter en formato clave-valor (fechas en formato europeo
|
||||
DD/MM/AAAA — memoria `formato-fecha-europeo`), cuerpo Markdown, y galería de attachments
|
||||
(imágenes con lightbox; PDFs como enlace/embed).
|
||||
|
||||
`sigma.js` y `graphology` son dependencias nuevas del frontend (no en `@fn_library`). KISS:
|
||||
añadir solo esas dos; el resto (tabla, layout, modales) sale de Mantine/`@fn_library`.
|
||||
|
||||
## Decisiones abiertas
|
||||
|
||||
1. **¿BD intermedia o lectura directa del vault?** v1 lee el vault en cada arranque (cachea el
|
||||
grafo en memoria). Si el vault crece mucho o se quiere histórico/diff, evaluar un
|
||||
`operations.db` con `entities`/`relations` (encaja con el bucle reactivo). Recomendado:
|
||||
empezar sin BD (KISS), añadirla solo si el rendimiento o un caso de uso lo exige.
|
||||
2. **Backend FastAPI vs stdlib http**: FastAPI da validación y OpenAPI gratis; stdlib evita una
|
||||
dependencia. Como el backend es fino (orquesta funciones del registry), decidir al construir.
|
||||
3. **Live-reload del vault**: ¿re-escanear bajo demanda (botón "refrescar") o watcher de
|
||||
filesystem? v1: botón refrescar (simple). Watcher si molesta.
|
||||
4. **Aristas dangling**: wikilinks a notas que aún no existen — ¿mostrarlos como nodos fantasma
|
||||
(útil para ver "objetivos pendientes de fichar") o esconderlos? Propuesta: nodo fantasma con
|
||||
estilo atenuado, toggle para ocultar.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: grafo carga el vault | e2e | `GET /api/graph` con el vault osint real | `nodes` ≥ nº de `.md`, `edges` con los wikilinks resueltos; sigma.js los pinta |
|
||||
| Golden: ficha con imágenes | e2e | `GET /api/node/<persona con fotos>` + abrir NodeCard | frontmatter + cuerpo + galería con las imágenes de `attachments/personas/<slug>/` |
|
||||
| Edge: tabla filtrada por tipo | e2e | `GET /api/nodes?tipo=organizacion` | solo nodos de ese tipo, columnas del frontmatter |
|
||||
| Edge: wikilink dangling | unit | nota con `[[Persona-Inexistente]]` | arista marcada dangling / nodo fantasma, sin crash |
|
||||
| Edge: nombre con mayúsculas/acentos | unit | wikilink `[[María del Mar]]` → slug | resuelve a `maria-del-mar-...md` vía `slugify_obsidian_name` |
|
||||
| Error: path traversal en attachment | e2e | `GET /api/attachment?path=../../etc/passwd` | 403/404, jamás sirve fuera del vault |
|
||||
| Error: vault inexistente | e2e | arrancar con `--vault /no/existe` | error claro al arrancar, no 500 silencioso |
|
||||
| Cobertura | audit | `uses_functions` del `app.md` | declara todas las funciones del grupo `obsidian` consumidas |
|
||||
|
||||
Vida útil (cuando se construya): usar la app de verdad sobre el vault osint durante ≥7 días en
|
||||
investigaciones reales; medir que el grafo sigue cargando sin romperse al crecer el vault.
|
||||
|
||||
## Notas
|
||||
|
||||
**Estado actual: solo plan.** No construir todavía — la recopilación de datos en Obsidian
|
||||
continúa; cuando el vault tenga masa crítica de objetivos/relaciones, se arranca con
|
||||
`/new-cpp-app` no aplica (es web): se hace `git init` del sub-repo `dataforge/osint_web` dentro de
|
||||
`projects/osint/apps/osint_web/` antes de limpiar cualquier worktree (regla `apps_subrepo.md`),
|
||||
scaffolding de frontend con el stack Mantine del registry, y backend Python orquestando el grupo
|
||||
`obsidian`.
|
||||
|
||||
Onboarding (para cuando exista): arrancar backend `python server/main.py --vault
|
||||
/home/enmanuel/Obsidian/osint --port 8470` y `pnpm dev` en `frontend/`; abrir
|
||||
`http://127.0.0.1:5173`. Pestañas: Grafo / Tablas / (ficha al click). Solo localhost por los
|
||||
datos sensibles del vault.
|
||||
|
||||
Relación con #0171 (manifest de sub-repos): cuando esta app exista será un hijo del project
|
||||
`osint` y debe entrar en su `subrepos.yaml` para re-clonarse en otros PCs.
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
id: "0167"
|
||||
title: "fn run de library function Go ejecuta go test del paquete entero (arrastra tests flaky vecinos)"
|
||||
status: completado
|
||||
type: enhancement
|
||||
domain:
|
||||
- registry-quality
|
||||
scope: registry-only
|
||||
priority: media
|
||||
depends: []
|
||||
blocks: []
|
||||
related: ["0077"]
|
||||
created: 2026-06-03
|
||||
updated: 2026-06-03
|
||||
tags: [fn-run, go, testing, flaky, dag-engine, reliability]
|
||||
---
|
||||
# 0167 — fn run de library function Go ejecuta go test del paquete entero
|
||||
|
||||
## APP Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0167 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | media |
|
||||
| **Tipo** | enhancement — dispatcher de `fn run` |
|
||||
|
||||
## Contexto
|
||||
|
||||
Cuando `fn run <id>` recibe una **library function Go sin `main.go`** que tiene tests
|
||||
declarados (`tested: true` + `test_file_path`), el dispatcher (`cmd/fn/run.go:171-181`)
|
||||
ejecuta:
|
||||
|
||||
```
|
||||
go test -v -count=1 -tags fts5 ./functions/<domain> # el PAQUETE ENTERO
|
||||
```
|
||||
|
||||
Es decir, no ejecuta "la función" (no se puede: no tiene `main`), sino que corre **todos
|
||||
los tests del paquete**. Consecuencia: el éxito de `fn run miFuncion` depende de que pasen
|
||||
los tests de **todas las demás funciones del mismo paquete**, no solo los suyos.
|
||||
|
||||
### Cómo se manifestó
|
||||
|
||||
Los DAGs `daily-registry-audit` y `weekly-deep-scan` del `dag_engine` invocaban funciones
|
||||
`*_go_infra` (`find_unused_functions`, `artefact_doctor`, etc.) como `function:` steps.
|
||||
Cada step disparaba `go test ./functions/infra` (paquete completo), que contiene tests
|
||||
impuros con recursos fijos:
|
||||
|
||||
- `TestSSHTunnelOpenClose` → `bind [127.0.0.1]:19876: Address already in use`
|
||||
- `TestDockerContainerExec` → `listen unix .../docker_exec_test.sock: bind: invalid argument` (path de socket > 108 chars con TMPDIR largo)
|
||||
|
||||
Al correr dos `function:` steps en paralelo (ambos `depends` del mismo padre), las dos
|
||||
invocaciones de `go test ./functions/infra` colisionaban en el **puerto fijo 19876** →
|
||||
una pasaba y la otra fallaba de forma no determinista. Resultado: el DAG fallaba sin
|
||||
auditar nada, y el fallo parecía "la auditoría encontró un problema" cuando en realidad
|
||||
era un test de red vecino.
|
||||
|
||||
> Nota: el síntoma operativo en los DAGs ya se resolvió por otra vía (2026-06-03): los
|
||||
> steps ahora usan `audit_doctor_snapshot_bash_infra` (Bash), que ejecuta `fn doctor <sub>`
|
||||
> real en vez de `go test` del paquete. Este issue es la **causa raíz general** del
|
||||
> dispatcher, que sigue afectando a cualquier `fn run <library_go_fn_con_tests>`.
|
||||
|
||||
## Problema
|
||||
|
||||
1. `fn run` de una library function NO ejecuta la función — corre el paquete de test entero.
|
||||
2. Los tests impuros de un paquete (puertos/sockets/red fijos) no son seguros para
|
||||
ejecuciones concurrentes ni reproducibles en cualquier entorno (TMPDIR, CI).
|
||||
3. Un único test flaky en `functions/infra` rompe `fn run` de las ~N funciones testeadas
|
||||
del paquete, y por extensión cualquier DAG/cron que las invoque.
|
||||
|
||||
## Opciones de solución (decidir en implementación)
|
||||
|
||||
### Opción A — library Go sin main → siempre compile-check (`go vet`/`go build`)
|
||||
`fn run <lib_fn>` significa "verifica que la función va"; para código sin `main` eso es
|
||||
"compila". Testear es responsabilidad de `go test` / CI, no de `fn run` en un cron.
|
||||
|
||||
- **Pro**: determinista, rápido, elimina el flaky de raíz.
|
||||
- **Contra**: rompe el comportamiento documentado en `CLAUDE.md` ("`fn run filter_slice_go_core`
|
||||
→ Go function con tests → `go test -v`"). Perderíamos la capacidad de correr los tests de
|
||||
una función vía `fn run`.
|
||||
|
||||
### Opción B — go test acotado con `-run` a los tests de la función
|
||||
Si la función declara sus tests, ejecutar solo esos:
|
||||
|
||||
```
|
||||
go test -v -count=1 -tags fts5 -run '^(TestX|TestY)$' ./functions/<domain>
|
||||
```
|
||||
|
||||
- **Pro**: aísla del flaky vecino manteniendo "fn run corre mis tests".
|
||||
- **Contra / RIESGO**: si los nombres de `fn.Tests` (frontmatter YAML, `registry/parser.go:32`)
|
||||
tienen **drift** respecto al código, `-run` no matchea y `go test` sale 0 con
|
||||
"no tests to run" → **falso-verde** en una primitiva crítica de todo el ecosistema.
|
||||
Mitigación obligatoria si se elige B: reconciliar `fn.Tests` con los tests extraídos por
|
||||
el indexer (`registry/test_parser.go::parseGoTests`, que ya puebla `unit_tests`) y/o
|
||||
detectar "0 tests ejecutados" parseando el output y tratarlo como fallo.
|
||||
|
||||
### Opción C — aislar los tests impuros del paquete
|
||||
Hacer robustos los tests culpables: puerto efímero (`:0` en vez de `19876`), socket en path
|
||||
corto bajo `/tmp` con nombre acotado, `t.Parallel`-safe. No cambia el dispatcher pero reduce
|
||||
la probabilidad de colisión.
|
||||
|
||||
- **Pro**: no toca `fn run` (cero blast radius sistémico).
|
||||
- **Contra**: no resuelve el problema conceptual (sigue corriendo el paquete entero); otros
|
||||
paquetes pueden introducir tests impuros nuevos y reincidir.
|
||||
|
||||
## Recomendación
|
||||
|
||||
Combinar **C** (saneamiento inmediato de `TestSSHTunnelOpenClose` y `TestDockerContainerExec`,
|
||||
bajo riesgo) con **B** endurecida (acotar `-run` + guard anti-falso-verde apoyado en
|
||||
`unit_tests` extraídos, no en el frontmatter manual). La Opción A es la más limpia
|
||||
conceptualmente pero rompe comportamiento documentado; evaluar si ese comportamiento
|
||||
("fn run corre los tests") aún se usa de verdad o puede deprecarse hacia `go test` directo.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: `fn run` de library fn testeada | e2e | `./fn run find_unused_functions_go_infra` | exit 0 sin depender de tests de funciones vecinas |
|
||||
| Edge: dos `fn run` concurrentes del mismo paquete | e2e | dos invocaciones en paralelo de funciones de `functions/infra` | ambas exit 0, sin colisión de puerto/socket |
|
||||
| Error: nombres de test con drift (si se elige B) | unit | `fn.Tests` con un nombre inexistente | NO produce falso-verde (se detecta "0 tests run" → fallo) |
|
||||
| Tests impuros saneados | unit | `go test -run 'TestSSHTunnelOpenClose\|TestDockerContainerExec' ./functions/infra` repetido 5× | 5/5 PASS deterministas |
|
||||
|
||||
## Resolución (2026-06-03)
|
||||
|
||||
Implementada la combinación **C + B** recomendada.
|
||||
|
||||
### C — Tests impuros saneados (`functions/infra/`)
|
||||
- `ssh_tunnel_test.go`: el puerto fijo `19876` pasa a **puerto efímero** (`freeTCPPort` pide `:0` al kernel). Elimina el `bind: address already in use` bajo concurrencia.
|
||||
- `docker_container_exec_test.go`: el socket Unix deja de colgar de `t.TempDir()` (path largo con el nombre del subtest) y usa un **directorio corto** bajo `/tmp` (`os.MkdirTemp("/tmp", "dk")` + cleanup). Elimina el `bind: invalid argument` por exceder los ~108 bytes de `sun_path`.
|
||||
- Verificado: `go test -run 'TestSSHTunnelOpenClose|TestDockerContainerExec' -count=5 ./functions/infra/` → `ok` (5×, determinista).
|
||||
|
||||
### B — `fn run` acota los tests a la función (`cmd/fn/run.go`)
|
||||
- Para una library Go function con tests, el dispatcher ahora añade
|
||||
`-run '^(<tests>)$'` con los nombres **extraídos por el indexer** (`unit_tests`,
|
||||
vía `db.GetUnitTestsByFunction`), no los del frontmatter `.md` (que pueden driftar).
|
||||
Así `fn run` ejecuta solo los tests de esa función, aislándola de tests flaky de
|
||||
funciones vecinas del mismo paquete. Si no hay nombres extraídos, cae al paquete
|
||||
entero (comportamiento previo).
|
||||
- **Guard anti-falso-verde**: `cmdRun` refleja el output de un `go test -run` a un
|
||||
buffer; si go test reporta `no tests to run` (que sale con exit 0), el run se trata
|
||||
como **fallo** (exit 1 + mensaje pidiendo `fn index`). Evita que un drift de nombres
|
||||
produzca un verde silencioso.
|
||||
|
||||
### Evidencia (DoD)
|
||||
|
||||
| Escenario | Resultado |
|
||||
|---|---|
|
||||
| Golden: `fn run find_unused_functions_go_infra` | Corre solo sus 2 tests (`TestFindUnusedFunctions_*`) en 0.06s, exit 0. No toca SSH/Docker. |
|
||||
| Edge concurrente: 2 `fn run` del paquete `infra` en paralelo | Ambos exit 0, sin colisión de puerto. |
|
||||
| Error/drift: `unit_tests` con nombre inexistente | `go test` da `[no tests to run]`; el guard lo intercepta → exit 1 con mensaje. NO falso-verde. |
|
||||
| Tests saneados 5× | `ok` determinista. |
|
||||
|
||||
`go vet ./cmd/fn/` y `go test ./cmd/fn/` verdes tras los cambios.
|
||||
|
||||
## Notas
|
||||
|
||||
- Archivos clave: `cmd/fn/run.go` (dispatcher, líneas 145-194), `registry/parser.go`
|
||||
(campo `Tests`), `registry/test_parser.go` (extracción de nombres de test),
|
||||
`functions/infra/ssh_tunnel_open_close_test.go` y `functions/infra/docker_container_exec_test.go`
|
||||
(tests culpables).
|
||||
- Relacionado con 0077 (fn-run-bash-output-mudo): familia de issues sobre la semántica y
|
||||
observabilidad de `fn run`.
|
||||
@@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func
|
||||
- `integrity.md` — Reglas de integridad y referencias cruzadas
|
||||
- `architecture.md` — Visión general del sistema
|
||||
- `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting)
|
||||
- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué)
|
||||
- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md)
|
||||
|
||||
## Tablas
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# ADR 0005 — Mantener el `.git` del repo padre ligero: no trackear artefactos hijos, purgar basura del historial, submódulos shallow
|
||||
|
||||
- **Fecha:** 2026-06-03
|
||||
- **Estado:** accepted
|
||||
|
||||
## Contexto
|
||||
|
||||
El `.git` del repo padre `fn_registry` había crecido a **475 MB**, un tamaño que ralentiza clones, `fn sync` y la operación diaria entre los tres PCs del ecosistema (`aurgi-pc`, `home-wsl`, `lucas-linux`). El diagnóstico identificó tres causas independientes, todas evitables:
|
||||
|
||||
1. **Artefactos hijos forzados al índice.** Pese a que el `.gitignore` ya tiene las reglas correctas (`apps/*/`, `analysis/*/`, `projects/*/`), un `.gitignore` no des-trackea archivos que ya estaban en el índice. Dos apps tenían contenido forzado: `apps/dag_engine/` (31 archivos: código Go + frontend + `app.md` + `README.md`) y `apps/shaders_lab/` (`app.md` + un binario `shaders_lab.exe`). El commit `d8db05e9` ("chore(dag_engine): app.md ... metadata trackeada por el padre") había trackeado dag_engine deliberadamente; esta decisión queda **anulada**. La convención correcta ya estaba demostrada por `projects/*/apps` (p.ej. `registry_dashboard`, `call_monitor`), que el padre no versiona en absoluto y funcionan bien.
|
||||
|
||||
2. **Basura en el historial.** Versiones antiguas de directorios que nunca debieron versionarse seguían viviendo en los commits pasados: `frontend/node_modules` (168 MB de binarios), `build/` raíz (54 MB de artefactos de compilación C++, 2299 archivos), `registry.db` (29 MB en ~7 versiones; regenerable con `fn index`) y `apps/shaders_lab/shaders_lab.exe` (~190 MB acumulados en ~10 versiones). En total ~440 MB de blobs muertos.
|
||||
|
||||
3. **Submódulos C++ con historia completa.** `cpp/vendor/{imgui,implot,implot3d,tracy,glfw,sdl3}` + `emsdk` son submódulos git legítimos (deps necesarias para compilar las apps imgui). Cada uno clonaba **toda la historia upstream**: `.git/modules` pesaba 338 MB para servir un working tree de 118 MB. imgui solo: 129 MB de `.git` (11.552 commits) para 8.9 MB de headers; sdl3: 146 MB (21.539 commits) para 55 MB de código. El proyecto compila contra **un único commit pinneado** por submódulo — el resto es historia ajena que nadie consulta.
|
||||
|
||||
## Decisión
|
||||
|
||||
Mantener el `.git` del padre ligero con tres medidas:
|
||||
|
||||
1. **El repo padre NO versiona el contenido de los artefactos hijos.** Cada app/analysis/project-app es un sub-repo Gitea independiente con su propio `.git` (ADR 0002); el padre solo conserva su metadata en `registry.db` (regenerable con `fn index`, que lee los artefactos del disco). Se sacan del índice con `git rm -r --cached` (con `--cached` SIEMPRE — sin él se borraría el working tree de los sub-repos). Único contenido versionado bajo `apps/` y `analysis/`: los marcadores `.gitkeep`. Bajo `projects/`: solo los `project.md`.
|
||||
|
||||
2. **El historial pasado se purga de basura con `git filter-repo`.** Se eliminan los blobs de `frontend/node_modules`, `build/` (raíz), `registry.db` y `apps/shaders_lab/shaders_lab.exe` de todos los commits. Esto reescribe la historia (cambian los SHAs) y requiere `git push --force`. Se añade `build/` (raíz) al `.gitignore` para evitar reincidencia (`node_modules`, `*.exe` y `registry.db` ya estaban).
|
||||
|
||||
3. **Los submódulos C++ se configuran shallow (`depth 1`).** Cada submódulo descarga solo el commit pinneado, no la historia upstream. Se marca `shallow = true` en `.gitmodules` para que los clones futuros nazcan shallow. El working tree mantiene el snapshot completo de cada dependencia, así que la compilación C++ no cambia.
|
||||
|
||||
## Cómo se ejecutó (2026-06-03)
|
||||
|
||||
```bash
|
||||
# 1. Untrack del índice (los archivos quedan en disco; los .git de los sub-repos conservan el código)
|
||||
git rm -r --cached apps/dag_engine apps/shaders_lab
|
||||
git commit -m "chore: untrack contenido de artefactos hijos (dag_engine, shaders_lab)"
|
||||
|
||||
# 2. Purga del historial (con git-filter-repo, descargado standalone)
|
||||
python3 git-filter-repo --strip-blobs-bigger-than 10M --force
|
||||
python3 git-filter-repo --invert-paths --path frontend/node_modules --path build \
|
||||
--path registry.db --path apps/dag_engine --path apps/shaders_lab --force
|
||||
git remote add origin <url> # filter-repo elimina el remote por seguridad
|
||||
git push --force origin master
|
||||
|
||||
# 3. Submódulos shallow (deinit + borrar el .git/modules full + re-clone --depth 1)
|
||||
for sm in cpp/vendor/{sdl3,imgui,tracy,glfw,implot,implot3d} emsdk; do
|
||||
git submodule deinit -f "$sm"
|
||||
rm -rf ".git/modules/$sm" # clave: deinit NO borra .git/modules
|
||||
git -c "submodule.$sm.shallow=true" submodule update --init --depth 1 "$sm"
|
||||
done
|
||||
sed -i '/^\turl = /a\\tshallow = true' .gitmodules
|
||||
git commit -m "chore: submodulos C++ en modo shallow (depth 1)" && git push origin master
|
||||
```
|
||||
|
||||
Resultado: `.git` **475 MB → 51 MB** (−89%). Desglose: `.git/objects` 137 MB → 16 MB (historial del registry limpio); `.git/modules` 338 MB → 35 MB (submódulos shallow). `cpp/vendor` en disco intacto (118 MB). `cmake configure` de `cpp/` OK con las deps shallow. Backup completo del `.git` pre-purga en `~/backups/fn_registry_purge_20260603/`.
|
||||
|
||||
## Alternativas descartadas
|
||||
|
||||
- **Solo `git rm --cached` sin purgar el historial.** Detiene el crecimiento futuro pero deja los ~440 MB de basura en los commits pasados. No reduce el `.git`. Insuficiente para el objetivo.
|
||||
- **Purgar solo `shaders_lab.exe`.** Mismo coste (force-push + re-clone en otros PCs) por mucha menos ganancia: deja `node_modules`, `build/` y `registry.db` en el historial.
|
||||
- **Borrar o purgar los submódulos C++.** Son deps legítimas necesarias para compilar las apps imgui. Purgarlas rompería la compilación. La vía correcta es shallow, no eliminación.
|
||||
- **`git filter-branch` en vez de `git-filter-repo`.** Más lento, deja `refs/original` y es propenso a errores. `filter-repo` es la herramienta recomendada por el propio git.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- **Force-push reescribe la historia del padre.** Los otros PCs (`aurgi-pc`, `home-wsl`) quedan con historia divergente y deben re-sincronizar: `git fetch origin && git reset --hard origin/master && git submodule update --init --recursive`. Trabajo local del padre sin pushear se pierde — verificar antes. Esta es la única parte irreversible y outward-facing; requiere confirmación humana explícita.
|
||||
- **Shallow es local y reversible.** No toca el repo padre ni los gitlinks; solo adelgaza el `.git` interno de cada submódulo. Reversible por dep con `git fetch --unshallow`. El `.gitmodules` con `shallow = true` hace que los clones frescos nazcan ligeros; los clones existentes deben re-aplicar el `deinit + rm .git/modules/<x> + update --depth 1` o re-clonar.
|
||||
- **Bumpear una dep shallow cuesta un `git fetch --depth 1 <commit>` extra** antes del checkout, porque el commit nuevo no está en el clon mínimo. Es fricción, no bloqueo.
|
||||
- **Se pierde `git log/blame/bisect` dentro de los submódulos** (la historia de SDL3/imgui), algo que casi nunca se hace en deps vendored.
|
||||
- **Operación repetible.** Si el `.git` vuelve a crecer por basura, el procedimiento de este ADR (untrack + filter-repo + shallow) es el runbook.
|
||||
|
||||
## Relación con otras reglas y ADRs
|
||||
|
||||
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses como sub-repos `dataforge/<name>`. Este ADR refuerza su corolario: el padre no versiona su contenido.
|
||||
- `.claude/rules/apps_subrepo.md` — gotcha de pérdida de código en worktrees; misma raíz (artefactos = sub-repos independientes).
|
||||
- `.claude/rules/db_locations.md` — `registry.db` solo en la raíz y regenerable; por eso es purgable del historial.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR 0006 — `reports/` como artefacto local para reportes de trabajo
|
||||
|
||||
- **Fecha:** 2026-06-06
|
||||
- **Estado:** accepted
|
||||
|
||||
## Contexto
|
||||
|
||||
Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas:
|
||||
|
||||
1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco.
|
||||
2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat.
|
||||
3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps.
|
||||
|
||||
Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia.
|
||||
|
||||
Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre.
|
||||
|
||||
## Decisión
|
||||
|
||||
Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**:
|
||||
|
||||
1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults.
|
||||
2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/<p>/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar.
|
||||
3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`.
|
||||
4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria.
|
||||
|
||||
Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia.
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea.
|
||||
- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR.
|
||||
- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable.
|
||||
- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos.
|
||||
- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`.
|
||||
- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`.
|
||||
- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea).
|
||||
- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`.
|
||||
- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte).
|
||||
- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea.
|
||||
|
||||
## Relación con otras reglas y ADRs
|
||||
|
||||
- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía.
|
||||
- `.claude/rules/reports.md` — convención operativa derivada de este ADR.
|
||||
- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado).
|
||||
- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps.
|
||||
- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps).
|
||||
- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea.
|
||||
@@ -62,3 +62,5 @@ Qué se aprendió después. Útil cuando un ADR se supersede.
|
||||
| [0002](0002-apps-analyses-as-dataforge-master.md) | Apps y analyses como sub-repos `dataforge/<name>` con branch master | accepted |
|
||||
| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted |
|
||||
| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted |
|
||||
| [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted |
|
||||
| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted |
|
||||
|
||||
@@ -23,7 +23,10 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [nlp](nlp.md) | 33 | Extraccion NLP: PDFs, OCR, chunking, GLiNER/GLiREL, dedup, agregacion de entities/relations |
|
||||
| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys |
|
||||
| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat |
|
||||
| [web-proxy](web-proxy.md) | 4 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas. Alternativa ligera a ZAP/Burp |
|
||||
| [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp |
|
||||
| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy |
|
||||
| [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO |
|
||||
| [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario |
|
||||
| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions |
|
||||
| [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas |
|
||||
| [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) |
|
||||
@@ -38,6 +41,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [sink](sink.md) | 11 | Funciones que escriben datos a destino externo (BD, dashboard, alerta, email). Nodos output |
|
||||
| [validator](validator.md) | 6 | Funciones que verifican datos/config contra reglas. Pre-flight de sinks y gates en DAGs |
|
||||
| [navegator](navegator.md) | 4 | Automatización de browser via CDP + AX tree + LLM: obtener, limpiar, chunkear AX tree y llamar a Claude CLI |
|
||||
| [whatsapp](whatsapp.md) | 3 | Operar WhatsApp Web por CDP sobre la pestaña existente (sin ventana ni foco): buscar/abrir chat, leer conversacion, enviar texto. Compone 4 primitivas CDP-Python (cdp_eval/type_chars/press_key/click_xy). No HTTP: WhatsApp usa WebSocket + cifrado E2E |
|
||||
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
|
||||
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
|
||||
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
|
||||
@@ -45,6 +49,15 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
|
||||
| [wireguard](wireguard.md) | 7 | Instalar, configurar, operar y monitorizar mesh WireGuard hub-and-spoke: keygen, hub setup, peer add/revoke, status JSON |
|
||||
| [matrix-mas](matrix-mas.md) | 5 | Migración Synapse→MAS: habilitar MSC3861, verificar login flows, parche .well-known, registro clientes OAuth2, syn2mas |
|
||||
| [mesh-3d](mesh-3d.md) | 3 | Carga y upload a GPU de meshes 3D (OBJ, GLB/glTF 2.0): loaders CPU + mesh_gpu_upload OpenGL |
|
||||
| [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos |
|
||||
| [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use |
|
||||
| [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI |
|
||||
| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) |
|
||||
| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados |
|
||||
| [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks |
|
||||
| [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments |
|
||||
| [market-intel](market-intel.md) | 8 | Inteligencia de mercado para captacion de clientes: scrapers de tendencias de productos/nichos (Amazon, Google Trends, TikTok, AliExpress) + precios de competencia, aterrizados en Postgres (pg_insert_rows/pg_apply_sql) y analizados en Metabase. Dispatcher ingest_market_trends invocado por dag_engine. TikTok/AliExpress por HTTP caen (anti-bot); pendiente browser CDP |
|
||||
| [onlyoffice](onlyoffice.md) | 3 | Operar ONLYOFFICE Desktop Editors (binario onlyoffice-desktopeditors) en Linux/X11 desde terminal via instancia aislada (slot HOME=/tmp/oo_<instance>): abrir un archivo en ventana propia, cerrar+reabrir para mostrar datos editados en disco (no hay reload nativo, Issue #2313), y matar el proceso del slot. Solo gestiona la ventana, NO edita ni crea archivos. Requiere X11 + wmctrl + xdotool. No confundir con el Document Server (web/Docker) |
|
||||
|
||||
## Como anadir grupo
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Capability: android
|
||||
|
||||
Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon Windows), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), logcat streaming. WSL2 -> Windows adb daemon, no requiere Android Studio.
|
||||
Toolbelt Android **Linux-first** (con fallback WSL2 legacy). Cubre: ADB (`adb_wsl` resuelve el adb nativo del SDK), AVD emulator management (list/start/stop/wait, geo-fix), APK lifecycle (`android_apk_install`, `android_app_clear`, `android_app_launch`, `android_uninstall`), Capacitor build pipelines (`capacitor_build_apk`, `deploy_capacitor_to_emulator`), build Gradle nativo (`gradle_*`, `init_kotlin_app`, `run_kotlin_app_tests`), logcat streaming. Usa el SDK nativo en `~/android-sdk` (via `install_android_sdk`); el adb/emulator de Windows solo se usa como fallback cuando se detecta WSL2.
|
||||
|
||||
Design system Compose: las apps Kotlin nativas (`init_kotlin_app`) heredan `FnTheme` + `FnTokens` del módulo `kotlin/functions/ui` (`fn.compose:ui`), con la paleta exacta de Mantine v9 dark + indigo (misma que web `@fn_library` y C++ `fn_tokens`).
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_SDK_WIN=<sdk_root>]` | Wrapper sourceable para usar adb.exe Windows desde WSL2. Resuelve binario, convierte paths, espera boot del emulador. |
|
||||
| `adb_wsl_bash_infra` | `source adb_wsl.sh [ADB=<path>] [ANDROID_HOME=<sdk_root>]` | Wrapper sourceable para resolver e invocar adb. Linux-first: usa el adb nativo del Android SDK ($ANDROID_HOME) o del PATH; fallback a adb.exe solo si detecta WSL2. Expone adb_run, adb_devices, adb_pick_serial, adb_s, adb_wait_boot. |
|
||||
| `android_apk_install_bash_infra` | `android_apk_install([--serial S], apk_path: string, package_name?: string, activity_name?: string) -> void` | Instala APK en device/emulador via adb y opcionalmente lanza la app. Multi-emulator via --serial. |
|
||||
| `android_app_clear_bash_infra` | `android_app_clear([--serial <S>], package: string) -> void` | Wipe app data + cache via pm clear. App keeps installed but factory-state. Multi-emulator via --serial. |
|
||||
| `android_app_info_bash_infra` | `android_app_info([--serial <S>], package, [--json]) -> stdout` | Inspect installed app: version, target SDK, activities via dumpsys package. |
|
||||
@@ -16,8 +18,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
|
||||
| `android_emu_battery_bash_infra` | `android_emu_battery([--serial <S>], level: int, [--charging <true\|false>]) -> void` | Simulate battery state on emulator (level + charging). Emulator-only. |
|
||||
| `android_emu_geo_fix_bash_infra` | `android_emu_geo_fix([--serial <S>], longitude: string, latitude: string, [altitude: string]) -> void` | Fake GPS location on Android emulator via emu geo fix. Emulator-only (not physical devices). |
|
||||
| `android_emu_rotate_bash_infra` | `android_emu_rotate([--serial <S>] [portrait\|landscape\|0\|90\|180\|270])` | Rotate emulator screen. Empty=toggle, or fixed orientation. Locks autorotate. |
|
||||
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles invocando emulator.exe Windows desde WSL2. |
|
||||
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD en background y espera a que termine de bootear. Idempotente: si ya hay emulador corriendo no lanza otro. |
|
||||
| `android_emulator_list_bash_infra` | `android_emulator_list([--json])` | Lista los AVDs disponibles. Linux-first: usa el emulator nativo del Android SDK ($ANDROID_HOME); fallback a emulator.exe solo bajo WSL2. |
|
||||
| `android_emulator_start_bash_infra` | `android_emulator_start(avd_name: string, timeout_s: int) -> string` | Arranca un AVD Android en background y espera a que termine de bootear. Linux-first: resuelve el emulator/adb nativos del SDK; fallback a binarios .exe solo bajo WSL2. Idempotente: si ya hay un emulador corriendo, imprime 'already running' y su serial sin lanzar otro. |
|
||||
| `android_emulator_stop_bash_infra` | `android_emulator_stop(serial?: string) -> void` | Para uno o todos los emuladores Android via adb emu kill. Si serial esta vacio, detecta todos los emulator-* activos y los para. Idempotente: exit 0 aunque no haya nada que matar. |
|
||||
| `android_input_keyevent_bash_infra` | `android_input_keyevent([--serial <S>] key: string)` | Send key event via adb shell input keyevent. Accepts aliases (BACK, HOME, POWER, ENTER, MENU, RECENT_APPS, VOLUME_UP, VOLUME_DOWN), raw numeric codes, or explicit KEYCODE_* names. |
|
||||
| `android_input_swipe_bash_infra` | `android_input_swipe([--serial <S>], x1: int, y1: int, x2: int, y2: int, [duration_ms: int])` | Send swipe gesture between two points with duration. |
|
||||
@@ -31,6 +33,8 @@ Toolbelt Android operable desde WSL2. Cubre: ADB (`adb_wsl`, conexion al daemon
|
||||
| `android_shell_bash_infra` | `android_shell([--serial <S>], cmd ...args)` | Execute arbitrary shell command on Android device. Multi-emulator via --serial. |
|
||||
| `capacitor_build_apk_bash_pipelines` | `capacitor_build_apk(web_app_dir: string, [app_id: string], [app_name: string]) -> void` | Pipeline que convierte una web app en un APK de Android usando Capacitor. Valida el entorno (ANDROID_HOME, Java 17+), construye el bundle web si no existe dist/, inicializa Capacitor si no está configurado, añade la plataforma Android, sincroniza y compila el APK con Gradle. El APK final queda en el directorio raíz de la web app. |
|
||||
| `deploy_capacitor_to_emulator_bash_pipelines` | `deploy_capacitor_to_emulator(app_dir: string, avd_name?: string, package_name?: string) -> void` | Pipeline end-to-end: build Capacitor APK + arranca AVD + instala + opcionalmente lanza la app. Valida que el AVD existe, construye el APK con capacitor_build_apk, arranca el emulador de forma idempotente, instala el APK y lanza la app si se da package_name. Imprime comando logcat sugerido al final. |
|
||||
| `fn_theme_kt_ui` | `@Composable fun FnTheme(darkMode: Boolean = true, content: @Composable () -> Unit)` | Provider raiz del design system Compose del registry (@fn_compose). Envuelve MaterialTheme con un ColorScheme derivado de FnColors (Mantine v9 dark + indigo). Dark por defecto, mirror de FnMantineProvider (web) y fn::run_app ThemeMode::FnDark (C++). Toda app del registry envuelve su contenido en FnTheme. |
|
||||
| `fn_tokens_kt_ui` | `object FnTokens { colors; spacing; radius; typography; shadows }` | Design tokens del design system Compose del registry (@fn_compose). Paleta heredada exacta (mismos hex) de cpp/DESIGN_SYSTEM.md / Mantine v9 dark + indigo: FnColors, FnSpacing (Dp), FnRadius (Dp), FnTypography (sp + weights), FnShadows (Dp). Fuente unica de valores visuales para apps Android del registry. |
|
||||
| `gradle_assemble_debug_bash_infra` | `gradle_assemble_debug(project_dir: string, module: string) -> string` | Build APK debug de un modulo Android via gradlew assembleDebug. |
|
||||
| `gradle_clean_bash_infra` | `gradle_clean(project_dir: string) -> int` | Limpia build artifacts de un proyecto Android (gradle clean + rm .gradle + rm build). |
|
||||
| `gradle_instrumented_test_bash_infra` | `gradle_instrumented_test(project_dir: string, module: string) -> int` | Corre instrumented tests Compose en emulador/device Android conectado. |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Capability: claude-direct
|
||||
|
||||
Hablar directamente con `https://api.anthropic.com/v1/messages` usando el token OAuth de Claude Code (Claude Max), sin lanzar la CLI `claude` ni necesitar una API key de pago separada. 3 funciones Python en `domain: core`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `load_claude_oauth_token_py_core` | `def load_claude_oauth_token(credentials_path: str = "", refresh_if_expired: bool = True) -> str` | Lee el access token OAuth desde `~/.claude/.credentials.json`. Verifica expiry (ms-epoch). Intenta refresh best-effort si expirado. |
|
||||
| `stream_anthropic_messages_py_core` | `def stream_anthropic_messages(messages: list, model: str = "claude-opus-4-8", ...) -> Iterator[dict]` | POST streaming a `/v1/messages`. Yield de eventos normalizados: `text`, `tool_use_start`, `tool_input_delta`, `done`, `error`. Parser SSE puro testeable por separado. |
|
||||
| `run_claude_tool_loop_py_core` | `def run_claude_tool_loop(messages, tools, dispatch, ...) -> dict` | Bucle agentico tool-use. Llama `stream_anthropic_messages` en loop, despacha tools via `dispatch{name: callable}`, anade `tool_result`, repite hasta `end_turn` o `max_iters`. |
|
||||
|
||||
## Ejemplo canonico end-to-end
|
||||
|
||||
### Pregunta simple (sin tools)
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from stream_anthropic_messages import stream_anthropic_messages
|
||||
|
||||
text = ""
|
||||
for event in stream_anthropic_messages(
|
||||
messages=[{"role": "user", "content": "di solo PONG"}],
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=32,
|
||||
):
|
||||
if event["type"] == "text":
|
||||
text += event["text"]
|
||||
print(event["text"], end="", flush=True)
|
||||
elif event["type"] == "done":
|
||||
print(f"\n[stop={event['stop_reason']}]")
|
||||
# Output: PONG
|
||||
# [stop=end_turn]
|
||||
```
|
||||
|
||||
### Bucle agentico con tool propia
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from run_claude_tool_loop import run_claude_tool_loop
|
||||
from datetime import datetime
|
||||
|
||||
tools = [
|
||||
{
|
||||
"name": "get_time",
|
||||
"description": "Devuelve la hora actual en formato HH:MM:SS.",
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
]
|
||||
|
||||
dispatch = {
|
||||
"get_time": lambda _inp: datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
|
||||
result = run_claude_tool_loop(
|
||||
messages=[{"role": "user", "content": "que hora es exactamente ahora?"}],
|
||||
tools=tools,
|
||||
dispatch=dispatch,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
on_text=lambda d: print(d, end="", flush=True),
|
||||
)
|
||||
print(f"\n[iters={result['iterations']} stop={result['stop_reason']}]")
|
||||
# Claude llama a get_time() -> "14:32:07"
|
||||
# Luego responde: "Ahora son las 14:32:07."
|
||||
```
|
||||
|
||||
### Solo leer el token (para uso manual)
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions/core")
|
||||
from load_claude_oauth_token import load_claude_oauth_token
|
||||
|
||||
token = load_claude_oauth_token(refresh_if_expired=False)
|
||||
# Pasar como header: {"authorization": f"Bearer {token}"}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **NO cubre** el flujo de refresh OAuth (endpoint no documentado publicamente) — el refresh es best-effort y puede fallar silenciosamente.
|
||||
- **NO es un cliente completo** de la API de Anthropic: solo `/v1/messages` con streaming. Files, embeddings, etc. quedan fuera.
|
||||
- **NO reemplaza** el uso de API keys oficiales para produccion — este grupo es exclusivamente para uso local del token OAuth de Claude Max.
|
||||
- **NO gestiona rate limits** — el caller debe manejar errores `{"type": "error"}` con `429` en el mensaje.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Claude Code instalado y usuario logueado (`~/.claude/.credentials.json` debe existir).
|
||||
- `httpx` disponible en el venv: `python/.venv/bin/python3 -c "import httpx"`.
|
||||
- Token fresco (Claude Code normalmente lo renueva en background mientras esta abierto).
|
||||
@@ -0,0 +1,106 @@
|
||||
# dav — Cliente CardDAV/CalDAV (Python, solo stdlib)
|
||||
|
||||
Grupo de capacidad para operar un servidor **CardDAV/CalDAV** (Xandikos, git-backed,
|
||||
en el VPS `magnus`) desde Python sin dependencias externas. Cubre el flujo de
|
||||
**migracion**: partir un export de Google (un `.vcf` con N contactos, un `.ics` con
|
||||
N eventos) en recursos individuales y subirlos uno a uno por HTTP PUT con Basic auth.
|
||||
Tambien listar y descargar recursos para verificar o hacer backup.
|
||||
|
||||
Formaliza el flujo ad-hoc (heredocs) que migro 820 contactos + 98 eventos a Xandikos
|
||||
(regla `function_growth_and_self_docs`: una composicion repetida >2 veces se promueve
|
||||
a funciones/pipelines del registry).
|
||||
|
||||
## Restriccion de diseno
|
||||
|
||||
**Solo stdlib** (`urllib.request`, `re`, `hashlib`, `base64`, `ssl`). Sin `requests`,
|
||||
`caldav` ni `vobject`. El header `Authorization: Basic base64(user:pass)` se construye
|
||||
a mano. `verify_tls=True` por defecto. Coherente con el grupo `osint-passive` (sin deps).
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Que hace | Purity |
|
||||
|---|---|---|---|
|
||||
| `split_vcards_py_infra` | `split_vcards(vcf_text) -> list` | Parte un `.vcf` en VCARDs individuales | pure |
|
||||
| `split_vevents_to_vcalendars_py_infra` | `split_vevents_to_vcalendars(ics_text, prodid?) -> list` | Parte un VCALENDAR con N VEVENT en N VCALENDARs autonomos (replica VTIMEZONE) | pure |
|
||||
| `extract_or_make_uid_py_infra` | `extract_or_make_uid(text, prefix?) -> str` | Extrae el `UID:` o sintetiza `<prefix><md5[:16]>` determinista | pure |
|
||||
| `carddav_put_vcard_py_infra` | `carddav_put_vcard(base_url, user, pw, coll, uid, vcard) -> dict` | PUT de un VCARD (`.vcf`, `text/vcard`) | impure |
|
||||
| `caldav_put_event_py_infra` | `caldav_put_event(base_url, user, pw, coll, uid, vcal) -> dict` | PUT de un VCALENDAR (`.ics`, `text/calendar`) | impure |
|
||||
| `dav_list_resources_py_infra` | `dav_list_resources(base_url, user, pw, coll) -> dict` | PROPFIND Depth:1 -> lista de `{href, etag}` | impure |
|
||||
| `dav_get_resource_py_infra` | `dav_get_resource(base_url, user, pw, href) -> dict` | GET de un recurso -> texto VCARD/VCALENDAR | impure |
|
||||
| `dav_make_calendar_py_infra` | `dav_make_calendar(base_url, user, pw, calendar_home, slug, name?, color?, desc?) -> dict` | MKCALENDAR + PROPPATCH: crea una coleccion de calendario (agenda) nueva | impure |
|
||||
| `dav_make_addressbook_py_infra` | `dav_make_addressbook(base_url, user, pw, contacts_home, slug, name?, desc?) -> dict` | Extended MKCOL: crea una coleccion CardDAV (libreta/agenda de contactos) nueva | impure |
|
||||
| `dav_list_addressbooks_py_infra` | `dav_list_addressbooks(base_url, user, pw, contacts_home) -> dict` | PROPFIND Depth:1: lista las libretas CardDAV del contacts-home con nombre y descripcion | impure |
|
||||
| `build_vcard_py_core` | `build_vcard(contact: dict) -> str` | Serializa un contacto a VCARD 3.0 MULTI-VALOR (N TEL/EMAIL/ADR + X-OSINT-*); pura | pure |
|
||||
| `expand_rrule_py_infra` | `expand_rrule(dtstart_ical, rrule, range_start, range_end, all_day?) -> list` | Expande una RRULE iCalendar a las fechas de cada ocurrencia dentro de un rango | pure |
|
||||
| `import_vcf_to_carddav_py_pipelines` | `import_vcf_to_carddav(vcf_path, base_url, user, pw, coll) -> dict` | Pipeline: .vcf -> split -> uid -> PUT por tarjeta | impure |
|
||||
| `import_ics_to_caldav_py_pipelines` | `import_ics_to_caldav(ics_path, base_url, user, pw, coll) -> dict` | Pipeline: .ics -> split -> uid -> PUT por evento | impure |
|
||||
|
||||
## Sistema real (para los ejemplos)
|
||||
|
||||
- Servidor: **Xandikos** en `https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com`, Basic auth, usuario `enmanuel`.
|
||||
- Password: `pass dav/xandikos-enmanuel` (primera linea). Resolver con `pass_get_secret_py_infra`, NUNCA hardcodear.
|
||||
- Principal: `/enmanuel/`. Colecciones:
|
||||
- CardDAV: `/enmanuel/contacts/addressbook/`
|
||||
- CalDAV: `/enmanuel/calendars/calendar/`
|
||||
|
||||
## Ejemplo canonico end-to-end
|
||||
|
||||
Importar un `.vcf` exportado de Google a Xandikos, leyendo la password de `pass`:
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
from pipelines.import_vcf_to_carddav import import_vcf_to_carddav
|
||||
|
||||
BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||
pw = pass_get_secret("dav/xandikos-enmanuel")["value"] # NO logear
|
||||
|
||||
summary = import_vcf_to_carddav(
|
||||
vcf_path="/home/enmanuel/Descargas/contacts.vcf",
|
||||
base_url=BASE,
|
||||
username="enmanuel",
|
||||
password=pw,
|
||||
collection_path="/enmanuel/contacts/addressbook/",
|
||||
)
|
||||
print(summary["ok"], summary["fail"], summary["total"]) # 820 0 820
|
||||
```
|
||||
|
||||
Verificar el resultado listando la coleccion:
|
||||
|
||||
```python
|
||||
from infra.dav_list_resources import dav_list_resources
|
||||
res = dav_list_resources(BASE, "enmanuel", pw, "/enmanuel/contacts/addressbook/")
|
||||
print(res["status"], len(res["resources"])) # ok 820
|
||||
```
|
||||
|
||||
El calendario es analogo con `import_ics_to_caldav` + `/enmanuel/calendars/calendar/`.
|
||||
|
||||
Desde la CLI del registry (resuelve la pass como variable, no la pongas en claro):
|
||||
|
||||
```bash
|
||||
PW=$(pass show dav/xandikos-enmanuel | head -n1)
|
||||
./fn run import_vcf_to_carddav /home/enmanuel/Descargas/contacts.vcf \
|
||||
https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com \
|
||||
enmanuel "$PW" /enmanuel/contacts/addressbook/
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No descubre el principal ni las colecciones**: hay que conocer los paths
|
||||
(`/enmanuel/contacts/addressbook/`, etc.). No implementa `current-user-principal`
|
||||
ni `addressbook-home-set` discovery.
|
||||
- **No hace sync incremental** real: `dav_list_resources` devuelve etags pero no
|
||||
hay logica de diff/merge. Re-importar es idempotente por UID (sobrescribe), no
|
||||
incremental.
|
||||
- **No parsea campos VCARD/VEVENT**: trata cada componente como texto opaco. Para
|
||||
transformar contenido (renombrar, deduplicar por nombre) usa otra herramienta.
|
||||
- **Solo VEVENT** en calendario: VTODO/VJOURNAL se ignoran al partir el `.ics`.
|
||||
- **Escrituras irreversibles**: los PUT sobrescriben en el servidor. Idempotente
|
||||
por UID pero no hay confirmacion previa; valida el `.vcf`/`.ics` antes de importar.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `pass` configurado con la entrada `dav/xandikos-enmanuel`.
|
||||
- Conectividad TLS al endpoint publico (`verify_tls=True`).
|
||||
- Python del registry: `python/.venv/bin/python3`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Capability: duckdb
|
||||
|
||||
Operar bases de datos DuckDB desde el registry: abrir/crear bases, consultas read-only seguras, conversion CSV -> Parquet, deduplicacion por hash y carga de series temporales. DuckDB es el motor analitico embebido del ecosistema (OLAP local, archivos `.duckdb`, lectura directa de CSV/Parquet/JSON).
|
||||
|
||||
Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (project `osint`): la app `osint_db` posee la DuckDB maestra y este grupo aporta las primitivas de acceso.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma | Que hace |
|
||||
|---|---|---|
|
||||
| `duckdb_open_go_infra` | `DuckDBOpen(path string) (*sql.DB, error)` | Abre (o crea) una base DuckDB desde Go. Path vacio o `:memory:` abre en memoria. |
|
||||
| `duckdb_query_readonly_py_infra` | `duckdb_query_readonly(db_path, sql, params=None, max_rows=10000) -> dict` | Consulta read-only segura: conexion `read_only=True`, params posicionales `?`, filas como `list[dict]` con tipos normalizados a JSON (date/datetime -> isoformat, Decimal -> float, bytes -> base64). Devuelve `{status, columns, rows, row_count, truncated}` sin lanzar. |
|
||||
| `duckdb_execute_py_infra` | `duckdb_execute(db_path, sql, params=None) -> dict` | Ejecuta UNA sentencia de escritura (INSERT/UPDATE/DELETE/DDL) en conexion read-write, commit, devuelve `{status, rowcount}` sin lanzar. Primitivo de escritura del grupo (complementa a `duckdb_query_readonly`). |
|
||||
| `duckdb_upsert_py_infra` | `duckdb_upsert(db_path, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ...` actualizando SOLO `update_cols`. Excluir columnas de `update_cols` permite que un re-upsert NO las pise (ownership selectivo: la DB es la verdad). Devuelve `{status, inserted, updated}`. |
|
||||
| `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. |
|
||||
| `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. |
|
||||
| `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). |
|
||||
|
||||
## Ejemplo canonico
|
||||
|
||||
Consulta read-only desde cualquier sesion (la conexion se abre `read_only=True` y se cierra siempre):
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry
|
||||
python/.venv/bin/python3 - <<'PYEOF'
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import duckdb_query_readonly
|
||||
|
||||
res = duckdb_query_readonly(
|
||||
"projects/osint/apps/osint_db/data/osint.duckdb",
|
||||
"SELECT contexto, COUNT(*) AS n FROM persons GROUP BY contexto ORDER BY n DESC",
|
||||
max_rows=50,
|
||||
)
|
||||
print(res["status"], res["row_count"])
|
||||
for row in res["rows"]:
|
||||
print(row)
|
||||
PYEOF
|
||||
```
|
||||
|
||||
Conversion CSV -> Parquet en una linea:
|
||||
|
||||
```bash
|
||||
./fn run csv_to_parquet_duckdb datos.csv datos.parquet
|
||||
```
|
||||
|
||||
## Gotchas del grupo
|
||||
|
||||
- **Single-writer**: DuckDB permite UN solo proceso escritor por archivo. Si un service (ej. `osint_db`) posee la base, el resto de procesos deben leer con `read_only=True` (`duckdb_query_readonly` ya lo hace) o pasar por la API HTTP del service. Las funciones de escritura (`duckdb_execute`, `duckdb_upsert`) abren en read-write y SOLO debe usarlas el proceso dueño de la base (dentro de su write lock), nunca un cliente concurrente.
|
||||
- **Version del motor**: el formato de archivo puede cambiar entre versiones mayores de DuckDB. El venv del registry lleva `duckdb` 1.5.x; no mezclar con CLIs/WASM antiguos sobre el mismo archivo.
|
||||
- `read_only=True` exige que el archivo exista — no crea bases nuevas.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO cubre SQLite (`sqlite_open_go_infra` y el grupo de operations.db van aparte).
|
||||
- NO cubre el render de resultados a Markdown/notas — eso es `render_markdown_table_py_core` + `upsert_sentinel_block_py_core` (grupo `obsidian`).
|
||||
- El analisis exploratorio pesado (notebooks) vive en `analysis/` con sus propios venvs.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
group: e2e-messaging
|
||||
description: "Criptografía extremo a extremo para bus de mensajería: identidades duales Ed25519/X25519, distribución de claves de sala con sealed box anónimo, cifrado simétrico AEAD por mensaje, y firma/verificación de mensajes."
|
||||
functions:
|
||||
- generate_identity_go_cybersecurity
|
||||
- seal_aead_go_cybersecurity
|
||||
- open_aead_go_cybersecurity
|
||||
- seal_key_box_go_cybersecurity
|
||||
- open_key_box_go_cybersecurity
|
||||
- sign_ed25519_go_cybersecurity
|
||||
- verify_ed25519_go_cybersecurity
|
||||
---
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `generate_identity_go_cybersecurity` | `GenerateIdentity() (Identity, error)` | Genera par Ed25519 (firma) + par X25519 (kex) para un participante |
|
||||
| `seal_aead_go_cybersecurity` | `SealAEAD(key, plaintext, aad []byte) (nonce, ct []byte, err error)` | Cifra mensaje con ChaCha20-Poly1305, nonce aleatorio por llamada |
|
||||
| `open_aead_go_cybersecurity` | `OpenAEAD(key, nonce, ct, aad []byte) ([]byte, error)` | Descifra y autentica; error explícito si el tag falla |
|
||||
| `seal_key_box_go_cybersecurity` | `SealKeyBox(recipientKexPub, secret []byte) ([]byte, error)` | Cifra room key para un destinatario con su X25519 pubkey (sealed box anónimo) |
|
||||
| `open_key_box_go_cybersecurity` | `OpenKeyBox(kexPub, kexPriv, sealedMsg []byte) ([]byte, error)` | Abre sealed box con el par X25519 propio para recuperar la room key |
|
||||
| `sign_ed25519_go_cybersecurity` | `SignEd25519(priv, msg []byte) []byte` | Firma determinista Ed25519 (pura, sin I/O) |
|
||||
| `verify_ed25519_go_cybersecurity` | `VerifyEd25519(pub, msg, sig []byte) bool` | Verifica firma Ed25519 (pura, sin I/O) |
|
||||
|
||||
## Ejemplo canónico end-to-end
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Cada participante genera su identidad una sola vez
|
||||
server, err := cs.GenerateIdentity()
|
||||
if err != nil { log.Fatal(err) }
|
||||
user, err := cs.GenerateIdentity()
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
// 2. Servidor genera room key y la distribuye al usuario cifrada
|
||||
roomKey := make([]byte, 32)
|
||||
// ... llenar roomKey con crypto/rand en producción ...
|
||||
sealed, err := cs.SealKeyBox(user.KexPub, roomKey)
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
// 3. Usuario recupera la room key
|
||||
gotKey, err := cs.OpenKeyBox(user.KexPub, user.KexPriv, sealed)
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
// 4. Usuario cifra un mensaje con la room key
|
||||
aad := []byte("room:sala-general:seq:1")
|
||||
nonce, ct, err := cs.SealAEAD(gotKey, []byte("hola sala"), aad)
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
// 5. Usuario firma el ciphertext para autenticar autoría
|
||||
sig := cs.SignEd25519(user.SignPriv, ct)
|
||||
|
||||
// 6. Receptor verifica firma y descifra
|
||||
if !cs.VerifyEd25519(user.SignPub, ct, sig) {
|
||||
log.Fatal("firma inválida")
|
||||
}
|
||||
plain, err := cs.OpenAEAD(gotKey, nonce, ct, aad)
|
||||
if err != nil { log.Fatal(err) }
|
||||
fmt.Printf("recibido: %s\n", plain)
|
||||
_ = server // server.SignPub publicado en directorio de participantes
|
||||
}
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
Este grupo cubre las primitivas criptográficas del bus, no el protocolo completo:
|
||||
|
||||
- **No cubre**: transporte (WebSocket, gRPC), gestión de sesiones, ratchet de claves (doble ratchet), persistencia de identidades, revocación de claves.
|
||||
- **No cubre**: cifrado de archivos adjuntos (usar SealAEAD directamente con una key derivada).
|
||||
- **No reemplaza**: libsodium ni libolm para implementaciones de producción de Signal/Matrix — estas funciones son el sustrato criptográfico, no el protocolo completo.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- `golang.org/x/crypto` ya en `go.mod` (presente en fn-registry).
|
||||
- `crypto/ed25519` de stdlib (Go 1.13+).
|
||||
- Identidades persistidas de forma segura (keyring, HSM, archivo cifrado): este grupo no gestiona almacenamiento.
|
||||
|
||||
## Patrón de uso recomendado
|
||||
|
||||
```
|
||||
GenerateIdentity() → persiste Identity por participante
|
||||
SealKeyBox(kexPub, roomKey) → distribuye room key al unirse a sala
|
||||
OpenKeyBox(kexPub, kexPriv) → recupera room key
|
||||
SealAEAD(roomKey, msg, aad) → cifra cada mensaje
|
||||
SignEd25519(signPriv, ct) → autentica autoría sobre ciphertext
|
||||
VerifyEd25519(signPub, ct) → verifica antes de descifrar
|
||||
OpenAEAD(roomKey, nonce, ct)→ descifra mensaje verificado
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Flow Replay — Guardar un flujo web como función reproducible
|
||||
|
||||
Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo
|
||||
una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una
|
||||
**función del registry reproducible sin intervención**. Materializa la doctrina del issue
|
||||
0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso.
|
||||
|
||||
Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`.
|
||||
|
||||
Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico,
|
||||
`flow-replay` lo **destila y reproduce**.
|
||||
|
||||
## El patrón: grabar → destilar → reproducir
|
||||
|
||||
Tres fases, con una jerarquía de reproducción de más barato a más caro:
|
||||
|
||||
```
|
||||
Fase 0 — GRABAR (una vez, siempre con browser + proxy)
|
||||
web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR
|
||||
(funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har)
|
||||
|
||||
Fase 1 — DESTILAR (del HAR a una secuencia de requests)
|
||||
har_filter_flows → descarta estáticos/analytics, deja los flujos que importan
|
||||
har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url,
|
||||
headers, cookies, body), aislando los datos de auth
|
||||
|
||||
Fase 2 — REPRODUCIR, en orden de preferencia:
|
||||
Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO.
|
||||
Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente,
|
||||
challenge JS o WAF con fingerprint de navegador. Reutiliza
|
||||
cdp_extract_recipe + cdp_save_storage_state (ver Fronteras).
|
||||
Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado
|
||||
(cdp_click_xy_human, cdp_move_mouse_human del dominio browser).
|
||||
```
|
||||
|
||||
La función-acción concreta que guardas en el registry (`reboot_<panel>_server`,
|
||||
`login_<panel>`, etc.) envuelve el nivel que funcione: idealmente una llamada a
|
||||
`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde
|
||||
`pass`/vault.
|
||||
|
||||
## Funciones del grupo
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. |
|
||||
| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. |
|
||||
| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. |
|
||||
|
||||
## Ejemplo canónico end-to-end
|
||||
|
||||
Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se
|
||||
encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2:
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from cybersecurity.har_filter_flows import har_filter_flows
|
||||
from cybersecurity.har_extract_calls import har_extract_calls
|
||||
from infra.http_replay_sequence import http_replay_sequence
|
||||
|
||||
# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har
|
||||
import json
|
||||
har = json.load(open(os.path.expanduser("~/sesion.har")))
|
||||
|
||||
# 2. Destilar: del ruido a la secuencia mínima
|
||||
flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel
|
||||
calls = har_extract_calls(flows) # call specs reproducibles
|
||||
|
||||
# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST.
|
||||
res = http_replay_sequence(
|
||||
calls,
|
||||
params={"server_id": "vps-42"}, # parametrizado por el caller
|
||||
extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}],
|
||||
verify_tls=True,
|
||||
)
|
||||
print(res["status"], [s["status_code"] for s in res["steps"]])
|
||||
```
|
||||
|
||||
Una vez validado, el flujo se promueve a una función-acción nombrada del registry
|
||||
(p. ej. `reboot_vps_server_<panel>`) que internamente llama a `http_replay_sequence`
|
||||
con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde
|
||||
`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces.
|
||||
|
||||
## Fronteras
|
||||
|
||||
- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza
|
||||
con un HAR ya existente.
|
||||
- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo
|
||||
qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`)
|
||||
y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección
|
||||
automática de tokens/CSRF sería una función nueva del grupo, no una ampliación.
|
||||
- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el
|
||||
patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un
|
||||
"action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo
|
||||
formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser`
|
||||
para saltarse el login. No se construye por adelantado (KISS / registry-first).
|
||||
- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El
|
||||
grupo nunca los hardcodea ni los persiste.
|
||||
|
||||
## Gotchas (seguridad — leer antes de usar)
|
||||
|
||||
- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto —
|
||||
gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de
|
||||
`har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`.
|
||||
- **Secretos a `pass`/vault**, nunca en el código de la función-acción.
|
||||
- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es
|
||||
destructivo. La función-acción debe pedir confirmación o exponer un flag explícito
|
||||
(`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible.
|
||||
- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que
|
||||
exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado).
|
||||
- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción
|
||||
JSON es dot-path simple (`a.b.0.c`), no JSONPath completo.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página.
|
||||
- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Capability group: `hoppscotch`
|
||||
|
||||
Operar una instancia **self-hosted de Hoppscotch** (consola de APIs, alternativa open-source a
|
||||
Postman) desde el registry, vía su **API GraphQL**. El agente crea/edita requests, colecciones y
|
||||
environments por la API; el humano los ve **en vivo** en su GUI (subscriptions = hot-reload real).
|
||||
Las requests viven en la base de datos del self-host (Postgres), compartida entre el agente y la GUI.
|
||||
|
||||
Este es el **flujo canónico**. El antiguo modo "archivo `.json` local" (funciones
|
||||
`parse_*` / `run_*` / `add_hoppscotch_request`) **fue eliminado**: escribía un `.json` en disco que
|
||||
NO subía al workspace, así que el humano no lo veía en la GUI. No lo reintroduzcas.
|
||||
|
||||
## Stack self-host
|
||||
|
||||
Vive en `projects/web_scraping/hoppscotch/selfhost/` (docker compose: AIO + Postgres + mailpit).
|
||||
|
||||
| Servicio | URL | Para qué |
|
||||
|---|---|---|
|
||||
| App (cliente) | `http://localhost:3009` | la GUI donde el humano usa las colecciones (instalable como PWA) |
|
||||
| Admin dashboard | `http://localhost:3100` | gestión (usuarios, config) |
|
||||
| Backend GraphQL | `http://localhost:3170/graphql` | la API que usan las funciones |
|
||||
| Mailpit | `http://localhost:8025` | captura el magic link del login (SMTP de pruebas, sin correo real) |
|
||||
|
||||
Levantar: `cd selfhost && docker compose up -d`. Team de trabajo: **"registry"**. Cuenta: `admin@example.com`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `hoppscotch_login_py_infra` | `(email, *, backend_url, mailpit_url) -> {access_token,...}` | login por magic link headless (lee el link de mailpit) → JWT |
|
||||
| `hoppscotch_create_request_py_infra` | `(collection_id, method, url, *, title, headers, body, body_type, team_id, access_token) -> dict` | crea una request en una colección de la team |
|
||||
| `hoppscotch_update_request_py_infra` | `(request_id, method, url, *, title, headers, body, body_type, access_token) -> dict` | actualiza una request |
|
||||
| `hoppscotch_delete_request_py_infra` | `(request_id, *, access_token) -> dict` | borra una request |
|
||||
| `hoppscotch_list_requests_py_infra` | `(collection_id, *, access_token) -> {requests:[...]}` | lista las requests de una colección |
|
||||
| `hoppscotch_set_environment_py_infra` | `(team_id, name, variables, *, access_token) -> dict` | crea/actualiza (idempotente) el environment de la team; resuelve secretos `pass:` |
|
||||
| `build_hoppscotch_collection_py_infra` | `(calls, *, name, request_names) -> dict` | **helper interno** de create/update: serializa call specs al formato HoppRESTRequest. NO para escribir `.json` a mano |
|
||||
| `pass_get_secret_py_infra` | `(path, *, line) -> {value}` | lee un secreto de `pass` (lo consume `set_environment` para no hardcodear keys) |
|
||||
|
||||
`access_token` se pasa como **cookie**, no header `Authorization`. Caduca a 24h → re-login con `hoppscotch_login`.
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.expanduser("~/fn_registry"), "python", "functions"))
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
from infra.hoppscotch_set_environment import hoppscotch_set_environment
|
||||
|
||||
TEAM = "cmq8kn0v500030xls1nvminjy" # team "registry"
|
||||
COLL = "cmq8knppc00040xlskt4ist27" # colección registry_api (de hoppscotch_list/DB)
|
||||
|
||||
tok = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# 1. Variables del workspace (secreto resuelto desde pass, no hardcodeado)
|
||||
hoppscotch_set_environment(TEAM, "registry", [
|
||||
{"key": "baseURL", "value": "https://registry.organic-machine.com", "secret": False},
|
||||
{"key": "api_key", "value": "pass:apis/registry", "secret": True}, # pass: -> pass_get_secret
|
||||
], access_token=tok)
|
||||
|
||||
# 2. Crear una request → aparece EN VIVO en la GUI del humano (subscriptions)
|
||||
hoppscotch_create_request(
|
||||
COLL, "GET", "<<baseURL>>/api/status",
|
||||
title="status", headers={"Accept": "application/json"},
|
||||
team_id=TEAM, access_token=tok,
|
||||
)
|
||||
```
|
||||
|
||||
## Fronteras (qué NO cubre)
|
||||
|
||||
- **No es modo archivo**: no escribe colecciones `.json` locales como fuente. Las requests viven en el
|
||||
Postgres del self-host. (Los `.json` en `collections/` son solo respaldo/semilla importable.)
|
||||
- **No automatiza la GUI**: opera por la API; la GUI la mira el humano.
|
||||
- **No gestiona usuarios/teams del dashboard**: eso es el admin dashboard (`:3100`).
|
||||
- **No ejecuta los scripts pre/post-request JS** de Hoppscotch.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `access_token` como **cookie** (`cookies={"access_token": tok}`), no `Authorization`. 24h de vida.
|
||||
- `createRequestInCollection` de esta instancia **exige `team_id`** en el input (no solo el collectionID).
|
||||
- Variables `<<var>>` se resuelven con el environment de la team (subscriptions las propagan a la GUI).
|
||||
- Secretos: usa `value="pass:<ruta>"` en `set_environment` → se resuelve de `pass`, nunca se hardcodea
|
||||
ni se logea en crudo.
|
||||
- El secreto viaja en claro al backend local por GraphQL — es local (`127.0.0.1`), aceptable.
|
||||
@@ -0,0 +1,54 @@
|
||||
# market-intel
|
||||
|
||||
Inteligencia de mercado para captación de clientes: scrapers de señales de demanda y
|
||||
tendencias de productos/nichos desde varias fuentes públicas, más vigilancia de precios de
|
||||
la competencia, aterrizados en Postgres y analizados con Metabase. Scheduling con
|
||||
`dag_engine`. Origen: proyecto `captacion_clientes`.
|
||||
|
||||
## Funciones
|
||||
|
||||
| ID | Firma corta | Qué hace |
|
||||
|---|---|---|
|
||||
| `scrape_amazon_bestsellers_py_datascience` | `(marketplace, categories, list_type, max_items)` | Amazon Best Sellers + Movers & Shakers (ranking real de demanda). HTTP, funciona. |
|
||||
| `scrape_google_trends_py_datascience` | `(keywords, geo, timeframe, include_related)` | Interés de búsqueda (0-100) + rising/top via pytrends. Backoff ante 429. |
|
||||
| `scrape_tiktok_creative_py_datascience` | `(country, kind, limit, period)` | TikTok Creative Center (hashtags/songs/creators). **Bloqueado por anti-bot vía HTTP**; pendiente browser CDP. |
|
||||
| `scrape_aliexpress_trending_py_datascience` | `(query, category, limit, ship_to)` | Productos populares AliExpress (orders/rating). **Bloqueado por captcha vía HTTP**; pendiente browser CDP. |
|
||||
| `scrape_competitor_prices_py_datascience` | `(targets) -> list[dict]` | Precio actual de una lista de URLs de competidores (cascada: selector → JSON-LD → meta → heurística). |
|
||||
| `pg_insert_rows_py_infra` | `(dsn, table, rows, add_snapshot_date=True)` | Insert append-only por lote en Postgres (execute_values parametrizado, añade snapshot_date). |
|
||||
| `pg_apply_sql_py_infra` | `(dsn, sql_path) -> int` | Aplica un `.sql` de migración a Postgres (idempotente con IF NOT EXISTS). |
|
||||
| `ingest_market_trends_py_pipelines` | `(source)` | Dispatcher: scrapea una fuente y la aterriza en su tabla. Lo invoca `dag_engine`. |
|
||||
|
||||
## Ejemplo canónico (end-to-end)
|
||||
|
||||
```bash
|
||||
# 1. (una vez) Stack Metabase + Postgres en Docker
|
||||
fn run init_metabase_go_infra --project captacion --metabase-port 3030 --pg-port 5433 \
|
||||
--pg-user captacion --pg-password "$(pass show captacion/postgres | head -1)"
|
||||
docker exec captacion-postgres psql -U captacion -d metabase -c "CREATE DATABASE trends OWNER captacion"
|
||||
|
||||
# 2. (una vez) Aplicar el schema
|
||||
python3 -c "import sys; sys.path.insert(0,'python/functions'); from infra import pg_apply_sql; \
|
||||
pg_apply_sql('postgresql://captacion:PW@localhost:5433/trends', 'projects/captacion_clientes/db/migrations/001_schema.sql')"
|
||||
|
||||
# 3. Ingesta una fuente (manual o vía dag_engine)
|
||||
fn run ingest_market_trends_py_pipelines amazon
|
||||
fn run ingest_market_trends_py_pipelines google_trends
|
||||
|
||||
# 4. dag_engine lo hace solo: dags market-intel-daily (06:30) y competitor-prices-hourly
|
||||
```
|
||||
|
||||
## Fronteras
|
||||
|
||||
- NO hace explotación ni bypass agresivo de anti-bot: TikTok/AliExpress por HTTP-directo
|
||||
caen desde datacenter; la vía robusta es el browser MCP/CDP (grupo `navegator`/`web-proxy`,
|
||||
doctrina `flow_replay.md`), aún no implementada para estas dos fuentes.
|
||||
- NO es un grupo de visualización: el análisis vive en Metabase (grupo `metabase`).
|
||||
- NO gestiona el scheduling: eso es `dag_engine` (grupo `scheduler`).
|
||||
- El DSN de Postgres y credenciales NO se hardcodean: van en `pass`/`.env` del proyecto.
|
||||
|
||||
## Notas
|
||||
|
||||
- Las tablas de `trends` son append-only particionadas por `snapshot_date` — pensadas para
|
||||
series temporales en Metabase (qué tendencia sube/baja). No correr en bucle apretado.
|
||||
- `competitor_prices` se nutre de la tabla `competitor_targets` (el usuario inserta los
|
||||
objetivos a vigilar: competidor + product_key + URL).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user