30 Commits

Author SHA1 Message Date
egutierrez 3b348670cc Merge remote-tracking branch 'origin/master' 2026-06-01 22:23:26 +02:00
egutierrez fc4180cbb3 chore: auto-commit (129 archivos)
- .claude/agents/fn-analizador/SKILL.md
- .claude/agents/fn-constructor/SKILL.md
- .claude/agents/fn-executor/SKILL.md
- .claude/agents/fn-mejorador/SKILL.md
- .claude/agents/fn-orquestador/SKILL.md
- .claude/agents/fn-recopilador/SKILL.md
- .claude/commands/app.md
- .claude/commands/compile.md
- .claude/commands/cpp-app.md
- .claude/commands/create_functions.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:23:12 +02:00
egutierrez 7eef442444 chore: auto-commit (7 archivos)
- python/functions/core/__init__.py
- python/functions/pipelines/metabase_bulk_add_users_to_group.md
- python/functions/pipelines/metabase_bulk_add_users_to_group.py
- cpp/apps/
- python/functions/core/clean_email.md
- python/functions/core/clean_email.py
- python/functions/core/clean_email_test.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:39:08 +02:00
egutierrez 876020addf chore: auto-commit (7 archivos)
- .mcp.json
- bash/functions/pipelines/full_git_push.sh
- python/pyproject.toml
- python/uv.lock
- bash/functions/infra/jupyter_mcp_serve.md
- bash/functions/infra/jupyter_mcp_serve.sh
- dev/issues/0166-app-to-app-dependencies-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 01:29:45 +02:00
egutierrez 469e40ba40 fix(infra): full_git_push auto-init handles .git without origin
Auto-init step skipped any dir with .git present, even when it lacked
an origin remote. Such dirs fell through to push step and failed with
"'origin' does not appear to be a git repository". Now skip only when
.git AND origin exist; otherwise run ensure_repo_synced to create the
Gitea repo + add origin + push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:34:21 +02:00
egutierrez fce88032ca chore: auto-commit (43 archivos)
- .mcp.json
- bash/functions/infra/write_mcp_jupyter_config.md
- bash/functions/infra/write_mcp_jupyter_config.sh
- cpp/CMakeLists.txt
- cpp/apps/chart_demo
- cpp/apps/shaders_lab
- cpp/functions/gfx/gl_framebuffer.cpp
- cpp/functions/gfx/gl_framebuffer.h
- cpp/functions/gfx/gl_framebuffer.md
- cpp/functions/gfx/mesh_gpu.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 17:28:47 +02:00
egutierrez fec8ebd4ec merge: origin/master into local 2026-05-27 18:48:28 +02:00
egutierrez f5f05e4624 chore: auto-commit (2 archivos)
- dev/issues/completed/0126-pipeline-launcher-migration-003.md
- dev/proposals_e2e_checks_0121/pipeline_launcher.yaml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:48:14 +02:00
egutierrez 532f3d0ea8 issue(0128): kanban file attachments — PR draft en dataforge/kanban#1 2026-05-27 10:53:13 +02:00
egutierrez fe65c5e527 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00
egutierrez de9bfec498 feat(compile): Wails support in /compile skill
- New helper: deploy_wails_exe_to_windows_bash_infra
  - taskkill + cp build/bin/<app>.exe to Desktop/apps/<app>/
  - cmd.exe /c start RELAUNCHES the app post-deploy (key diff vs cpp)
  - preserves local_files/, copies appicon.ico if present
- New pipeline: compile_wails_app_bash_pipelines
  - resolve_cpp_app_dir (reused) + wails build -platform windows/amd64
  - auto -tags goolm if app declares matrix_crypto_init
  - delegates deploy + relaunch to deploy_wails_exe_to_windows
- /compile skill dispatches by framework:
  - wails.json present -> compile_wails_app (relaunches)
  - CMakeLists.txt present -> compile_cpp_app (no relaunch)

Refs: matrix_client_pc + matrix_admin_panel (issues 0147, 0163)
2026-05-25 03:11:09 +02:00
egutierrez e9c64a4687 chore(issues): close 0166 livekit TURN deploy
Integrated LiveKit TURN deployed on organic-machine.com:
- UDP 3478 + TCP 5349 (not 443 — Traefik HTTP/3 owns it)
- Wildcard cert *.organic-machine.com extracted from Traefik acme.json
- Subdomain turn-matrix-rtc-320bd4.organic-machine.com (wildcard DNS+cert)
- VPS commit f7f5303 in egutierrez/element_matrix_chat

DoD acceptance items requiring real-world CGNAT call testing
deferred to operator (no agent way to test mobile 4G NAT).
2026-05-25 00:46:43 +02:00
egutierrez 70ec825e32 chore(issues): close 0167+0168+0169+0170 livekit hardening bundle
VPS commit: 8eef89b (egutierrez/element_matrix_chat)

- 0167: STUN leak fixed (use_external_ip:false + node_ip hardcoded)
- 0168: UDP range expanded 50000-50200 -> 50000-50500
- 0169: API secret rotated (old key LK44e009c6e92b -> new LK5f6b38bb)
- 0170: livekit.example.yaml refreshed + header comments cleaned

Verification:
- 0 STUN packets to Google during restart (tcpdump 60s window)
- Endpoint /livekit/sfu/ HTTP 200
- LiveKit logs: nodeIP=135.125.201.30, portICERange=[50000,50500]
- Containers livekit + livekit-jwt healthy

New secret stored in pass: matrix/livekit-secret-rotation-2026-05-25
2026-05-25 00:44:15 +02:00
egutierrez 22692c1ed2 feat(matrix): 4 synapse quick wins applied + 6 follow-up issues
Server-side homeserver.yaml on organic-machine VPS:
- encryption_enabled_by_default_for_room_type: invite -> all
- presence.enabled: false (block EDU metadata leak)
- url_preview_enabled: false (block SSRF + IP leak)
- msc4108 rendezvous endpoint uncommented (QR login)

Synapse restarted, /versions shows e2ee_forced.* + msc4108 unstable
features active. Backup at synapse_data/homeserver.yaml.bak.1779659423.

Issues opened for remaining gaps:
- 0165 LUKS for media_store (at-rest encryption)
- 0166 LiveKit TURN deploy (NAT traversal gap)
- 0167 STUN leak to Google (hardcode external_ip)
- 0168 UDP range expand 200 -> 500
- 0169 LIVEKIT_SECRET rotation (audit exposure)
- 0170 livekit.example.yaml rename hygiene

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:53:37 +02:00
egutierrez d128ad89ac feat(matrix-mas): 3 helpers for matrix_client_pc (issue 0147)
- mas_oidc_loopback_go_infra: OAuth2 PKCE + loopback HTTP for desktop login
- keyring_token_store_go_infra: persist OAuth tokens in SO keychain
- matrix_client_init_go_infra: init mautrix.Client from access_token + whoami

Plus go.work workspace including matrix_client_pc sub-repo for shared
import path during dev. All 3 fns tagged matrix-mas capability group.

Tests: TestMasOidcLoopback (15 cases), TestKeyringTokenStore (5 cases,
SKIP on headless), TestMatrixClientInit (6 cases) — all green/skip.

Refs: dev/issues/0147-matrix-client-pc-scaffold.md
Refs: dataforge/matrix_client_pc commit f28c2b1
2026-05-24 23:23:49 +02:00
egutierrez bd9f0d8437 feat(matrix): MAS migration helpers + 2 flows + 15 issues + capability group
Helper functions (matrix-mas capability group):
- mas_client_register_bash_infra: register/sync OAuth clients via mas-cli
- mas_syn2mas_migration_bash_infra: dry-run + apply user migration to MAS
- synapse_msc3861_enable_go_infra: edit homeserver.yaml MSC3861 block (with diff)
- wellknown_oidc_patch_go_infra: patch well-known JSON with msc2965.authentication
- synapse_login_flows_check_go_infra: health-check post-migration login flows

Flows + issues for custom Matrix clients (PC + Android):
- 0010 matrix-client-pc: Wails + React+Mantine (issues 0147-0153)
- 0011 matrix-client-android: Kotlin + Compose (issues 0154-0161)
- 0162 enable MAS as auth provider (Synapse delegate) — EXECUTED on VPS
- 0163 custom admin panel propio (sustituye synapse-admin)

Production state (organic-machine.com):
- Synapse migrated SQLite -> Postgres
- MSC3861 active, password_config disabled
- 21 users + 41 access_tokens migrated via syn2mas
- 4 MAS clients registered (element, matrix_pc, matrix_android, admin_panel)
- synapse-admin container removed + Coolify route deleted
- well-known patched with org.matrix.msc2965.authentication

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:53:33 +02:00
egutierrez 207c08c3b7 merge(0133): columnar snapshot + string pool + reader rewire (1+2+3)
Foundation (ea5c94fc) + reader rewire (01bc2aeb).

- ColumnSnapshot per col (i64/f64/str_ids) + StringPool per-State
- compute_visible_rows filter/sort uses snapshot direct numeric/id compare
- StringPool realloc-crash fix (reserve before emplace_back)
- Pool staleness sentinel (rebuild when string_pool.size() drift)
- High-cardinality cap (>2048 unique → skip interning, fallback raw)

API publica intacta. Bench 100k sort_numeric +131% vs baseline.
text_editor_smoke RED preexisting unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:22:38 +02:00
egutierrez 01bc2aeb14 feat(0133-3): wire filter/sort readers to columnar snapshot
Change 3 of issue 0133 — rewire compute_visible_rows, filter eval,
and sort comparators to read from the SnapshotCache when available.

Hot paths rewired:
- compute_visible_rows (overload with snap): filter eval uses
  compare_snap (fast i64/f64 numeric compare for Int/Float cols;
  id-compare for low-cardinality string Eq/Neq; raw cells fallback
  for Contains/StartsWith/EndsWith).
- Sort comparators: direct i64/f64 array compare for Int/Float cols
  (goto sort_done skips string fallback); string sort uses uint32_t
  id compare with pool lookup only on mismatch.
- Stage>0 filter/sort: same snapshot overload.

Materialization paths (build_so, s0_backing, mat_backing, config popup)
kept on raw cells — they copy into std::string anyway, no benefit from
snapshot and snprintf-per-cell was 2M extra calls per frame.

Bug fixes (required for correctness):
1. StringPool::intern() realloc safety: force reserve before
   emplace_back so string_view keys in the map never go dangling.
2. SnapshotCache::pool_size_built sentinel: detects when a new State
   is created with an empty pool but same cells pointer (begin_scenario
   pattern). Prevents str_ids from indexing into an empty pool (SIGSEGV).
3. Cardinality cap (2048 uniques / 25% sample): high-cardinality string
   cols (timestamps-as-strings, UUIDs, names) skip interning — str_ids
   stays empty and compare_snap falls back to raw cells. Prevents 30MB+
   pool bloat that hurt cache for filter/sort on other cols.

Bench delta vs baseline (100k rows, LIBGL_ALWAYS_SOFTWARE=1):
  linear_scroll: 16.0 -> 15.5 fps p50  (-3%, baseline already FAIL)
  filter_like:   59.7 -> 56.0 fps p50  (-6%, still PASS at 56fps)
  sort_numeric:   3.9 ->  9.0 fps p50 (+131%, snapshot i64 sort)
  color_rule:    15.2 -> 14.8 fps p50  (-3%, baseline already FAIL)

Build: green for all 10 available Linux consumers (text_editor_smoke
linker failure is preexisting, not caused by this change).

API public intact. TableEvent.row indexing TableInput preserved.
Pointer-identity invalidation preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:21:09 +02:00
egutierrez 9ec7751f6f docs(0133): MIGRATION.md + growth log placeholder + drift fix
- modules/data_table/MIGRATION.md: porting guide + release checklist 1.0.0-stable
- data_table.md: growth log entry commented for post-gate bump
- data_table.md: fix error_type Go remnant ("error_go_core" -> "") in C++ module
- cpp/CMakeLists.txt: SQLite3 optional dep for data_table_bench (cross-windows)
- agent_cleanup_worktree.go: !windows build tag (uses unix-only syscalls)
- dev/issues/0133-cpp-data-table-10m-rows.md: issue tracking

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:56:14 +02:00
egutierrez fef86250a0 fix(0132): terminal_panel black bg + prompt input + cross-platform demos + e2e
terminal_panel.cpp:
  - BeginChild con PushStyleColor(ChildBg, negro) + PushStyleColor(Text, gris claro)
  - PushStyleVar(WindowPadding, 8/6px) para padding terminal real
  - Input prompt siempre visible cuando readonly=false
  - Prefijo "$ " antes del InputText (TextUnformatted + SameLine)
  - BeginDisabled() cuando el shell esta cerrado (en vez de ocultar el widget)
  - Calculo de child_h reserva exactamente GetFrameHeightWithSpacing+6 para el prompt

cpp/tests/e2e/test_terminal_panel_e2e.py (nuevo):
  - 4 asserts: PNG existe, no todo-blanco, region oscura >= 30%, pixels no-negros >= 0.3%
  - Lanza primitives_gallery --capture, busca el binario Linux o Windows.exe automaticamente
  - Skip graceful si no hay GL ni binario (WSL/CI headless)
  - 4/4 pasan en Linux con LIBGL_ALWAYS_SOFTWARE=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:48:42 +02:00
egutierrez 472b6092bb feat(0133): register data_table_bench in cpp/CMakeLists.txt
Adds the add_subdirectory block for apps/data_table_bench so the build
system picks it up. The app itself lives in its own sub-repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:38:17 +02:00
egutierrez ea5c94fc8a feat(0133-1+2): columnar snapshot + string interning in data_table
Change 1 — Columnar Snapshot Internal:
- Add ColumnSnapshot struct (type + str_ids/i64/f64 per column) in data_table_internal.h
- Add SnapshotCache struct with pointer-identity sentinel (last_cells_ptr)
- Add SnapshotCache field to UiState singleton
- In render(): rebuild snapshot after join materialization when cells ptr changes
  Uses same pointer-identity pattern as existing stats_last_cells in State
  Int/Float columns parsed once via parse_number; String/Auto interned

Change 2 — String Interning:
- Add StringPool struct (strings + unordered_map<string_view, uint32_t>) to data_table_types.h
- StringPool is per-State (NOT global) for table isolation
- intern(sv) inserts if absent, returns stable uint32_t index
- Cleared + rebuilt on each snapshot rebuild for index coherence
- Add string_pool field to State struct

Documentation:
- Extended header comment in data_table_internal.h describing design,
  StringPool API, invariants (pointer-identity, row→snapshot_row),
  and how stats_last_cells and snapshot coexist independently

Build: fn_module_data_table + tables_qa pass, no new errors (only
pre-existing -Wformat-truncation warnings unrelated to this change).
Public API (data_table.h, TableInput, render() signature) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:35:12 +02:00
egutierrez a8b09ad154 feat(0132): cpp terminal_panel module + ansi_parser
Nuevo modulo reutilizable terminal_panel (fn_term) para ImGui:

Sub-fn ansi_parser_cpp_core (cpp/functions/core/):
- Parser ANSI/VT100 byte-a-byte sin heap allocs por evento
- SGR colores FG/BG 16-color + bold + reset
- Cursor moves CUU/CUD/CUF/CUB + CUP absoluto
- Erase ED(2)/EL(2), CR/LF/BS
- Statemachine 4 estados, thread-unsafe por diseno
- 21 tests unitarios (57 assertions), todos pasan

terminal_panel_cpp_viz (cpp/functions/viz/terminal_panel/):
- terminal_panel.cpp: render ImGui + process_output con list clipper
- terminal_panel_linux.cpp: forkpty + reader thread no-blocking
- terminal_panel_windows.cpp: ConPTY CreatePseudoConsole (SDK >= 17763)
- Scrollback circular configurable (default 5000 lineas)
- Toolbar: clear, copy, reset, scroll-lock + status indicator
- readonly mode: sin input box, send() es no-op
- uses_functions: ansi_parser_cpp_core, logger_cpp_core

Tests:
- test_ansi_parser.cpp: 21 test cases, 57 assertions (PASS)
- test_terminal_panel_smoke.cpp: 3 test cases (PASS: spawn echo hello,
  process exits cleanly, readonly ignores send)

CMake:
- cpp/tests/CMakeLists.txt: add test_ansi_parser + test_terminal_panel_smoke
- primitives_gallery (sub-repo): ver commit separado en apps/primitives_gallery

Pendiente (anti-scope v1):
- Windows ConPTY: stub funcional que compila; join() del reader thread
  via std::thread no implementado (usa CreateThread detached)
- ANSI 256/24-bit color, italics, Unicode wide
- Curses pesados (vim, htop, top) — cursor visible basic solo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:35:11 +02:00
egutierrez 6aa874f2b6 done(0131): agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 23:10:32 +02:00
egutierrez 93352a7780 feat(issues): 0131 agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 22:47:02 +02:00
Egutierrez 0ffae6daa4 feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:20:15 +02:00
egutierrez 74b58cf0d0 fix(http_request): drop "2>&1" on Windows — CreateProcessW has no shell
POSIX popen routes via /bin/sh -c, so "2>&1" is a shell redirect. On
Windows we use CreateProcessW directly (no shell): curl receives "2>&1"
as a positional arg, treats it as a second URL, and fails with exit 3
"URL rejected: Bad hostname".

Stderr is already merged into the same pipe via STARTUPINFOW.hStdError
on Windows, so the redirect is also unnecessary there. Guard with
#ifndef _WIN32.

Also adds FN_HTTP_DEBUG env var to dump the cmdline + req.url for
future bug triage (zero-cost when unset).

Detected via agents_dashboard.exe --connect-test against
https://agents.organic-machine.com — same .exe with the fix now returns
"OK 11" in <2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:15:37 +02:00
egutierrez 9752fb106a done: 0128 + 0129 — agents_and_robots HTTP API + agents_dashboard C++ ImGui
Both issues delivered end-to-end:

0128 (backend, merged via dataforge/agents_and_robots/pulls/1):
- HTTP daemon in cmd/launcher with apikey Bearer auth + SSE
- LIVE at https://agents.organic-machine.com via Coolify Traefik + LE cert
- systemd Restart=always
- Unified status autodetect fix applied

0129 (frontend, merged via dataforge/agents_dashboard/pulls/1):
- C++ ImGui app in projects/element_agents/apps/agents_dashboard
- 4 panels: Connection / Agents / Logs / Status
- secret_store_cpp_infra new function (DPAPI Windows / XOR Linux)
- Deployed to Windows Desktop, App Hub tarjeta visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:58:05 +02:00
egutierrez 8cb0121573 merge: 0129 agents_dashboard scaffold + secret_store_cpp_infra + CMakeLists register 2026-05-22 21:57:04 +02:00
egutierrez 90115270d2 chore: auto-commit (3 archivos)
- cpp/functions/infra/secret_store.cpp
- cpp/functions/infra/secret_store.h
- cpp/functions/infra/secret_store.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:52:37 +02:00
362 changed files with 30642 additions and 680 deletions
+8 -8
View File
@@ -42,10 +42,10 @@ Opcionalmente:
```bash
# Por id
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE id = '<app_id>';"
# Por dir_path
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, dir_path FROM apps WHERE dir_path = '<dir>';"
```
Si no hay match → reportar y abortar.
@@ -78,8 +78,8 @@ APP_DB="$APP_DIR/operations.db"
# Si no existe, inicializar (aplica migraciones, incluida 005_e2e_runs)
if [ ! -f "$APP_DB" ]; then
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init "$APP_DIR"
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init "$APP_DIR"
fi
# Verificar tabla e2e_runs existe (migracion 005)
@@ -97,7 +97,7 @@ Hay dos caminos:
**Camino A — invocar funcion del registry (preferido):**
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
./fn run e2e_run_checks_go_infra ...
```
@@ -139,15 +139,15 @@ func main() {
Ejecutar con:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
CGO_ENABLED=1 go run -tags fts5 /tmp/run_e2e_<id>.go /tmp/checks.yaml "$APP_DIR"
```
### 5. Eval assertions activas (si la app las tiene)
```bash
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db "$APP_DB"
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db "$APP_DB"
```
Capturar fallos como warning checks adicionales.
+38 -38
View File
@@ -15,20 +15,20 @@ Eres el agente constructor del fn_registry. Tu rol es crear funciones, tests y t
```bash
# Buscar si ya existe algo similar (OBLIGATORIO antes de crear)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Buscar tipos existentes
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Ver funciones de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'DOMINIO' ORDER BY name;"
# Ver tipos de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO';"
# Verificar que un ID referenciado existe
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = 'ID_AQUI';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = 'ID_AQUI';"
```
Si algo similar ya existe, informa al usuario y sugiere mejorarlo en vez de duplicarlo.
@@ -39,13 +39,13 @@ Antes de implementar logica desde cero, busca funciones del registry que puedas
```bash
# Buscar funciones reutilizables por lo que hacen (ampliar con OR y prefijos)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:filter* OR description:map* OR description:transform*') ORDER BY name;"
# Ver que retorna y que tipos usa una funcion candidata
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, returns, uses_types FROM functions WHERE id = 'ID_CANDIDATO';"
# Buscar funciones puras del mismo dominio (las mas componibles)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature FROM functions WHERE domain = 'DOMINIO' AND purity = 'pure' ORDER BY name;"
```
**Criterios de reutilizacion:**
@@ -78,38 +78,38 @@ Esto acelera la construccion y fortalece el grafo de dependencias del registry.
| `bash` | `bash/functions/{domain}/{name}.sh` | `bash/functions/pipelines/{name}.sh` | *(no aplica)* |
| `typescript` | `frontend/functions/{domain}/{name}.ts` | *(no aplica)* | `frontend/types/{domain}/{name}.ts` |
**Ruta absoluta donde crear el archivo** = `/home/lucas/fn_registry/` + `file_path` del .md.
**Ruta absoluta donde crear el archivo** = `$HOME/fn_registry/` + `file_path` del .md.
Ejemplo: si `lang: bash` y `domain: infra`, el archivo va en:
- `/home/lucas/fn_registry/bash/functions/infra/{name}.sh` + `.md`
- **NUNCA** en `/home/lucas/fn_registry/functions/infra/{name}.sh`
- `$HOME/fn_registry/bash/functions/infra/{name}.sh` + `.md`
- **NUNCA** en `$HOME/fn_registry/functions/infra/{name}.sh`
### Estructura detallada
**Go** (carpeta raiz: `functions/` y `types/`)
- Funciones: `/home/lucas/fn_registry/functions/{domain}/{name}.go` + `.md`
- Tests: `/home/lucas/fn_registry/functions/{domain}/{name}_test.go`
- Tipos: `/home/lucas/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `/home/lucas/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
- Pipelines: `/home/lucas/fn_registry/functions/pipelines/{name}.go` + `.md`
- Funciones: `$HOME/fn_registry/functions/{domain}/{name}.go` + `.md`
- Tests: `$HOME/fn_registry/functions/{domain}/{name}_test.go`
- Tipos: `$HOME/fn_registry/functions/{domain}/{name}.go` (codigo, mismo paquete Go) + `$HOME/fn_registry/types/{domain}/{name}.md` (metadata con file_path apuntando a functions/)
- Pipelines: `$HOME/fn_registry/functions/pipelines/{name}.go` + `.md`
- Paquete Go = nombre del directorio (core, finance, datascience, cybersecurity, infra, shell, tui, io)
**Python** (carpeta raiz: `python/`)
- Funciones: `/home/lucas/fn_registry/python/functions/{domain}/{name}.py` + `.md`
- Tests: `/home/lucas/fn_registry/python/functions/{domain}/{name}_test.py`
- Tipos: `/home/lucas/fn_registry/python/types/{domain}/{name}.py` + `.md`
- Pipelines: `/home/lucas/fn_registry/python/functions/pipelines/{name}.py` + `.md`
- Funciones: `$HOME/fn_registry/python/functions/{domain}/{name}.py` + `.md`
- Tests: `$HOME/fn_registry/python/functions/{domain}/{name}_test.py`
- Tipos: `$HOME/fn_registry/python/types/{domain}/{name}.py` + `.md`
- Pipelines: `$HOME/fn_registry/python/functions/pipelines/{name}.py` + `.md`
**Bash** (carpeta raiz: `bash/`)
- Funciones: `/home/lucas/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
- Tests: `/home/lucas/fn_registry/bash/functions/{domain}/{name}_test.sh`
- Pipelines: `/home/lucas/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
- Funciones: `$HOME/fn_registry/bash/functions/{domain}/{name}.sh` + `.md`
- Tests: `$HOME/fn_registry/bash/functions/{domain}/{name}_test.sh`
- Pipelines: `$HOME/fn_registry/bash/functions/pipelines/{name}.sh` + `.md`
- Tipos: Bash no tiene tipos — usar solo `uses_types` para referenciar tipos de otros lenguajes
**TypeScript** (carpeta raiz: `frontend/`)
- Funciones puras: `/home/lucas/fn_registry/frontend/functions/core/{name}.ts` + `.md`
- Componentes React: `/home/lucas/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
- Funciones puras: `$HOME/fn_registry/frontend/functions/core/{name}.ts` + `.md`
- Componentes React: `$HOME/fn_registry/frontend/functions/ui/{name}.tsx` + `.md`
- Tests: junto al archivo, `{name}.test.ts` o `{name}.test.tsx`
- Tipos: `/home/lucas/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
- Tipos: `$HOME/fn_registry/frontend/types/{domain}/{name}.ts` + `.md`
---
@@ -591,7 +591,7 @@ Documentar completamente el .md igualmente.
1. **BUSCAR** en registry.db con FTS5 si existe algo similar
2. **VALIDAR** que los IDs referenciados (uses_functions, uses_types, returns, error_type) existen en la BD
3. **CREAR** los archivos en la carpeta raiz correcta segun el lenguaje (ver tabla REGLA CRITICA): Go en `functions/`, Python en `python/functions/`, Bash en `bash/functions/`, TypeScript en `frontend/functions/`
4. **INDEXAR** ejecutando: `cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index`
4. **INDEXAR** ejecutando: `cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index`
5. **VERIFICAR** con: `./fn show {id}` que se indexo correctamente
6. Si hay errores de validacion, corregirlos y re-indexar
@@ -600,10 +600,10 @@ Documentar completamente el .md igualmente.
1. **LEER** la funcion existente (codigo + .md) desde la BD: `sqlite3 registry.db "SELECT code, signature FROM functions WHERE id = '...'"`
2. **CREAR** el archivo de test
3. **EJECUTAR** los tests:
- Go: `cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
- Python: `cd /home/lucas/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
- Go: `cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 -run TestNombre ./functions/{domain}/`
- Python: `cd $HOME/fn_registry/python && python -m pytest functions/{domain}/{name}_test.py`
- TypeScript: desde `frontend/`, ejecutar con el test runner configurado
- Bash: `cd /home/lucas/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
- Bash: `cd $HOME/fn_registry && bash bash/functions/{domain}/{name}_test.sh`
4. **ACTUALIZAR** el .md con `tested: true`, `tests: [...]` y `test_file_path`
5. **RE-INDEXAR** y verificar
@@ -620,19 +620,19 @@ Documentar completamente el .md igualmente.
```bash
# Compilar CLI (necesario si se modifico codigo del CLI)
cd /home/lucas/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
cd $HOME/fn_registry && CGO_ENABLED=1 go build -tags fts5 -o fn ./cmd/fn/
# Indexar registry
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
# Tests Go de un dominio
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./functions/{domain}/
# Tests Go de todo el registry
cd /home/lucas/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
cd $HOME/fn_registry && CGO_ENABLED=1 go test -tags fts5 ./...
# Mostrar funcion indexada
cd /home/lucas/fn_registry && ./fn show {id}
cd $HOME/fn_registry && ./fn show {id}
```
### fn run — Ejecutar funciones y pipelines directamente
@@ -640,7 +640,7 @@ cd /home/lucas/fn_registry && ./fn show {id}
Despues de crear/indexar, puedes ejecutar directamente con `fn run`:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Go pipeline (go run . en su directorio)
./fn run init_metabase --project test
@@ -729,7 +729,7 @@ Peticion: "Crea una funcion que calcule la media de un slice de float64"
### Paso 1: Buscar en BD
```bash
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:mean* OR name:average* OR description:media* OR description:average*') ORDER BY name;"
```
### Paso 2: Crear archivos
@@ -823,6 +823,6 @@ func TestMean(t *testing.T) {
### Paso 3: Indexar y verificar
```bash
cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index
cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index
./fn show mean_go_core
```
+68 -68
View File
@@ -35,22 +35,22 @@ Las apps estan indexadas en registry.db con toda la metadata necesaria para ejec
```bash
# Ver todas las apps disponibles
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, description, entry_point, dir_path FROM apps ORDER BY name;"
# Ver app completa con dependencias y framework
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, entry_point, dir_path, uses_functions, uses_types, framework, tags FROM apps WHERE id = 'APP_ID';"
# Buscar apps por FTS (nombre, descripcion, tags, documentacion)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Apps de un dominio
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description, entry_point FROM apps WHERE domain = 'DOMINIO';"
# Apps que usan una funcion especifica
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name FROM apps WHERE uses_functions LIKE '%funcion_id%';"
# Ver documentacion completa de una app
sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
sqlite3 $HOME/fn_registry/registry.db "SELECT documentation, notes FROM apps WHERE id = 'APP_ID';"
```
**Campos clave de apps para ejecucion:**
@@ -65,19 +65,19 @@ sqlite3 /home/lucas/fn_registry/registry.db "SELECT documentation, notes FROM ap
```bash
# Ver pipeline/funcion completa
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description, uses_functions, uses_types FROM functions WHERE id = 'ID_AQUI';"
# Ver codigo de la funcion
sqlite3 /home/lucas/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
sqlite3 $HOME/fn_registry/registry.db "SELECT code FROM functions WHERE id = 'ID_AQUI';"
# Pipelines disponibles (con tag launcher para TUI)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE kind = 'pipeline' ORDER BY name;"
# Funciones impuras ejecutables directamente
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, signature, description FROM functions WHERE purity = 'impure' AND kind = 'function' ORDER BY name;"
# Buscar por FTS
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
```
### Usar contexto de apps para ejecucion inteligente
@@ -98,10 +98,10 @@ Cuando te pidan ejecutar una app, sigue este flujo:
```bash
# Desde la raiz del registry
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Opcion A: Usar el CLI
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
# Opcion B: Copiar template directamente
cp fn_operations/project_template/operations.db apps/{app_name}/operations.db
@@ -221,10 +221,10 @@ Las entities representan los datos concretos del proyecto. Las relations documen
### Crear entities (datos que el pipeline consume o produce)
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Entity de entrada
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
--db apps/{app_name}/operations.db \
--name "btc_ticks" \
--type-ref "tick_go_finance" \
@@ -235,7 +235,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
--metadata '{"pair":"BTCUSDT","exchange":"binance"}'
# Entity de salida
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity add \
--db apps/{app_name}/operations.db \
--name "btc_ohlcv_5m" \
--type-ref "ohlcv_go_finance" \
@@ -249,7 +249,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity add \
### Crear relations (como se conectan entities)
```bash
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation add \
--db apps/{app_name}/operations.db \
--name "ticks_to_ohlcv" \
--from-entity "{entity_id}" \
@@ -262,13 +262,13 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation add \
```bash
# Listar entities
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops entity list --db apps/{app_name}/operations.db
# Listar relations
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops relation list --db apps/{app_name}/operations.db
# Ver grafo ASCII
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
```
---
@@ -280,7 +280,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/ope
`fn run` despacha automaticamente segun el lenguaje y tipo:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Go pipeline (go run . en su directorio)
./fn run init_metabase --project test
@@ -318,13 +318,13 @@ Para apps con su propio main.go/main.py/main.sh:
```bash
# Go app
cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . [flags]
# Python app
cd /home/lucas/fn_registry/apps/{app_name} && python3 main.py [args]
cd $HOME/fn_registry/apps/{app_name} && python3 main.py [args]
# Bash app
cd /home/lucas/fn_registry/apps/{app_name} && bash main.sh [args]
cd $HOME/fn_registry/apps/{app_name} && bash main.sh [args]
```
### Capturar metricas de ejecucion
@@ -340,7 +340,7 @@ Al ejecutar, siempre captura:
```bash
# Ejemplo: ejecutar con captura de tiempo
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
OUTPUT=$(cd /home/lucas/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
OUTPUT=$(cd $HOME/fn_registry/apps/{app_name} && CGO_ENABLED=1 go run -tags fts5 . 2>&1)
EXIT_CODE=$?
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -362,7 +362,7 @@ echo "Status: $STATUS | Start: $START | End: $END"
### Via CLI
```bash
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
--db apps/{app_name}/operations.db \
--pipeline-id "tick_to_ohlcv_go_finance" \
--relation-id "{relation_id}" \
@@ -396,16 +396,16 @@ sqlite3 apps/{app_name}/operations.db "INSERT INTO executions (id, pipeline_id,
```bash
# Listar todas
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db
# Por pipeline
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --pipeline-id "ID"
# Por status
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list --db apps/{app_name}/operations.db --status failure
# Detalle de una ejecucion
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution show --db apps/{app_name}/operations.db --id "EXEC_ID"
```
---
@@ -441,12 +441,12 @@ Si hay assertions definidas sobre las entities afectadas, evaluarlas para verifi
```bash
# Evaluar assertions de una entity
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
--db apps/{app_name}/operations.db \
--entity-id "ENTITY_ID"
# Evaluar Y reaccionar (actualiza status de entities, crea proposals si hay fallos criticos)
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval \
--db apps/{app_name}/operations.db \
--entity-id "ENTITY_ID" \
--react
@@ -467,10 +467,10 @@ Cuando el usuario pide ejecutar algo que aun no tiene app:
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
mkdir -p $HOME/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: go
@@ -490,7 +490,7 @@ dir_path: "apps/{app_name}"
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
@@ -499,7 +499,7 @@ build/
GIEOF
# 4. Inicializar modulo Go
cd /home/lucas/fn_registry/apps/{app_name}
cd $HOME/fn_registry/apps/{app_name}
go mod init fn_registry/apps/{app_name}
# 5. Crear main.go minimo
@@ -523,8 +523,8 @@ func main() {
GOEOF
# 6. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
# 7. Indexar en registry.db
./fn index
@@ -534,10 +534,10 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
mkdir -p $HOME/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: py
@@ -557,7 +557,7 @@ dir_path: "apps/{app_name}"
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
@@ -565,7 +565,7 @@ __pycache__/
GIEOF
# 4. Crear main.py
cat > /home/lucas/fn_registry/apps/{app_name}/main.py << 'PYEOF'
cat > $HOME/fn_registry/apps/{app_name}/main.py << 'PYEOF'
"""Pipeline executor."""
import sys
import time
@@ -584,8 +584,8 @@ if __name__ == "__main__":
PYEOF
# 5. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
# 6. Indexar en registry.db
./fn index
@@ -595,10 +595,10 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
```bash
# 1. Crear directorio
mkdir -p /home/lucas/fn_registry/apps/{app_name}
mkdir -p $HOME/fn_registry/apps/{app_name}
# 2. Crear app.md (OBLIGATORIO)
cat > /home/lucas/fn_registry/apps/{app_name}/app.md << 'MDEOF'
cat > $HOME/fn_registry/apps/{app_name}/app.md << 'MDEOF'
---
name: {app_name}
lang: bash
@@ -618,14 +618,14 @@ dir_path: "apps/{app_name}"
MDEOF
# 3. Crear .gitignore
cat > /home/lucas/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
cat > $HOME/fn_registry/apps/{app_name}/.gitignore << 'GIEOF'
operations.db
operations.db-wal
operations.db-shm
GIEOF
# 4. Crear main.sh
cat > /home/lucas/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
cat > $HOME/fn_registry/apps/{app_name}/main.sh << 'SHEOF'
#!/usr/bin/env bash
# Pipeline executor: {app_name}
set -euo pipefail
@@ -650,11 +650,11 @@ main() {
main "$@"
SHEOF
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
# 5. Inicializar operations.db
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
# 6. Indexar en registry.db
./fn index
@@ -669,7 +669,7 @@ Este patron captura todo lo necesario para registrar la ejecucion:
### Go
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
APP_DIR="$HOME/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
PIPELINE_ID="{pipeline_id}"
RELATION_ID="{relation_id}" # vacio si no aplica
@@ -689,8 +689,8 @@ else
fi
# Registrar ejecucion
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "$PIPELINE_ID" \
--status "$STATUS" \
@@ -704,7 +704,7 @@ rm -f "$STDOUT_FILE" "$STDERR_FILE"
### Python
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
APP_DIR="$HOME/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -716,8 +716,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STATUS="success"
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "{pipeline_id}" \
--status "$STATUS" \
@@ -728,7 +728,7 @@ FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
### Bash
```bash
APP_DIR="/home/lucas/fn_registry/apps/{app_name}"
APP_DIR="$HOME/fn_registry/apps/{app_name}"
OPS_DB="$APP_DIR/operations.db"
PIPELINE_ID="{pipeline_id}"
@@ -741,8 +741,8 @@ END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
STATUS="success"
[ $EXIT_CODE -ne 0 ] && STATUS="failure"
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution add \
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution add \
--db "$OPS_DB" \
--pipeline-id "$PIPELINE_ID" \
--status "$STATUS" \
@@ -758,10 +758,10 @@ Antes de ejecutar, verifica que los snapshots de tipos en operations.db estan al
```bash
# Verificar snapshots
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
# Actualizar si estan desactualizados
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
```
---
@@ -800,7 +800,7 @@ Crea una proposal cuando detectes:
### Como crear proposals
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Proposal para nueva funcion
./fn proposal add \
@@ -840,7 +840,7 @@ Cuando la proposal viene de un fallo o anomalia en una ejecucion, incluye la evi
```bash
# Obtener el ID de la ejecucion que evidencia el problema
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops execution list \
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops execution list \
--db apps/{app_name}/operations.db --status failure
# Incluir evidencia en la descripcion
@@ -858,19 +858,19 @@ Usa el contexto de la tabla apps para comparar y detectar patrones:
```bash
# Ver que funciones usan las apps — detectar patrones comunes
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, uses_functions FROM apps WHERE uses_functions != '[]';"
# Ver funciones mas usadas por apps (candidatas a mejora)
sqlite3 /home/lucas/fn_registry/registry.db "
sqlite3 $HOME/fn_registry/registry.db "
SELECT f.value as func_id, COUNT(*) as uso
FROM apps, json_each(apps.uses_functions) f
GROUP BY f.value ORDER BY uso DESC;"
# Ver apps que NO tienen funciones del registry (candidatas a extraccion)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, description FROM apps WHERE uses_functions = '[]';"
# Ver si ya existe una proposal para algo similar
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, status, title FROM proposals WHERE status = 'pending' ORDER BY created_at DESC;"
```
### Flujo de deteccion al ejecutar
+5 -5
View File
@@ -43,12 +43,12 @@ APP_ID="<input>"
RUN_ID="<input>"
# dir_path desde registry
DIR_PATH=$(sqlite3 /home/lucas/fn_registry/registry.db \
DIR_PATH=$(sqlite3 $HOME/fn_registry/registry.db \
"SELECT dir_path FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
APP_ID=$(sqlite3 /home/lucas/fn_registry/registry.db \
APP_ID=$(sqlite3 $HOME/fn_registry/registry.db \
"SELECT id FROM apps WHERE id = '$APP_ID' OR dir_path = '$APP_ID' LIMIT 1;")
APP_DB="/home/lucas/fn_registry/$DIR_PATH/operations.db"
APP_DB="$HOME/fn_registry/$DIR_PATH/operations.db"
[ ! -f "$APP_DB" ] && APP_DB="/tmp/$(basename $DIR_PATH)_e2e_runs.db"
# Sanity check
@@ -93,7 +93,7 @@ Por cada fallo:
Antes de crear proposal, verificar que no haya una identica abierta:
```bash
sqlite3 /home/lucas/fn_registry/registry.db "
sqlite3 $HOME/fn_registry/registry.db "
SELECT id FROM proposals
WHERE status = 'pending'
AND target_id = '$APP_ID'
@@ -139,7 +139,7 @@ Sugerencia generica en `description` (NO codigo concreto, solo direccion):
Si la misma assertion/check ha disparado proposal mas de 3 veces en los ultimos 30 dias, marcar `priority` (campo extendido si existe, si no, anotar en `description: '[REINCIDENTE x4]'`).
```bash
sqlite3 /home/lucas/fn_registry/registry.db "
sqlite3 $HOME/fn_registry/registry.db "
SELECT COUNT(*) FROM proposals
WHERE target_id = '$APP_ID'
AND title LIKE '%::$CHECK_ID%'
+14 -14
View File
@@ -30,14 +30,14 @@ Referencia completa: `dev/issues/0069-autonomous-agent-loop-self-iterating-tasks
6. **Auditoria total**. Cada decision se loggea en `task_runs.progress_json` con razonamiento + fase + run_id.
7. **No self-modify**. NO modificas tu propio SKILL.md ni el de otros subagentes en la misma run.
8. **Cero produccion**. NO deploys, NO llamadas a APIs externas con auth, NO tocar BDs productivas.
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `/home/lucas/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `/home/lucas/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
9. **NUNCA paths absolutos fuera del worktree**. SIEMPRE rutas relativas o absolutas que apunten dentro de `/tmp/fn_orq_<issue>_<ts>/`. Si necesitas leer algo del repo principal (ej. plantillas docs), copialo al worktree primero. Refuerzo del piloto 1 (2026-05-15): orquestador modifico hooks bash del repo principal usando paths absolutos `$HOME/fn_registry/bash/functions/...` para destrancar pre-commit. Solucion correcta: el fix vive en el worktree, NO en main.
10. **Pre-commit hook compartido**. Worktrees comparten `.git/hooks/` con main repo. Si el hook llama scripts via path absoluto a main (ej. `$HOME/fn_registry/bash/functions/cybersecurity/scan_secrets_in_dirty.sh`), el hook ejecutara la version de MAIN, no la del worktree. Opciones legitimas:
a. Aplicar el fix del hook EN EL WORKTREE y commitearlo en `auto/*` — al mergear el PR, main heredara el fix.
b. Si el hook bloquea progreso y el fix del hook excede tu scope, `git commit --no-verify` para ESE commit SOLO, documentando excepcion en `task_runs.events_json[].decision="skip_hook"` con razon.
NO modificar archivos en main directamente.
11. **Post-iteracion sanity check**. Tras cada commit en `auto/*`, verificar:
```bash
git -C /home/lucas/fn_registry status --short
git -C $HOME/fn_registry status --short
```
Si la salida cambia respecto al baseline (capturado al inicio del piloto), HAS contaminado el repo principal. ABORT con `status=sandbox_breach` y reporta los archivos afectados en el output al humano.
@@ -49,24 +49,24 @@ Antes de arrancar el bucle, comprobar:
```bash
# 1. Migration 006_task_runs.sql existe
ls /home/lucas/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
ls $HOME/fn_registry/fn_operations/migrations/006_task_runs.sql 2>/dev/null \
|| { echo "ABORT: migration 006_task_runs.sql ausente. Aplicar issue 0069 paso 1 antes."; exit 2; }
# 2. Subagentes fn-* presentes
for a in fn-constructor fn-executor fn-recopilador fn-analizador fn-mejorador; do
test -f /home/lucas/fn_registry/.claude/agents/$a/SKILL.md \
test -f $HOME/fn_registry/.claude/agents/$a/SKILL.md \
|| { echo "ABORT: subagente $a ausente"; exit 2; }
done
# 3. master local up-to-date con origin (worktree se creara desde master)
git -C /home/lucas/fn_registry fetch origin master --quiet
LOCAL=$(git -C /home/lucas/fn_registry rev-parse master)
REMOTE=$(git -C /home/lucas/fn_registry rev-parse origin/master)
git -C $HOME/fn_registry fetch origin master --quiet
LOCAL=$(git -C $HOME/fn_registry rev-parse master)
REMOTE=$(git -C $HOME/fn_registry rev-parse origin/master)
test "$LOCAL" = "$REMOTE" \
|| { echo "ABORT: master local desincronizado con origin. git pull antes."; exit 2; }
# 4. Branch auto/<issue> NO existe ya (ni local ni en worktrees)
git -C /home/lucas/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
git -C $HOME/fn_registry rev-parse --verify "auto/${ISSUE_ID}" >/dev/null 2>&1 \
&& { echo "ABORT: branch auto/${ISSUE_ID} ya existe. Limpiar antes (git branch -D + worktree remove)."; exit 2; }
# 5. gh CLI autenticado (necesario para PR draft al converger)
@@ -116,7 +116,7 @@ BRANCH="auto/${ISSUE_ID}"
TASK_RUN_ID="task_$(openssl rand -hex 8)"
STARTED_AT=$(date +%s)
WT_ROOT="/tmp/fn_orq_${ISSUE_ID}_${STARTED_AT}"
REPO="/home/lucas/fn_registry"
REPO="$HOME/fn_registry"
# Crear worktree aislado desde master (no toca el principal)
git -C "$REPO" worktree add -b "$BRANCH" "$WT_ROOT" master \
@@ -187,13 +187,13 @@ while iter < max_iterations and elapsed < max_minutes:
Usar `Agent` tool con `subagent_type` correcto. Prompt **autocontenido** (paths absolutos, IDs, criterio exito).
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `/home/lucas/fn_registry/`.
**CRITICO**: pasar `WT_ROOT` (worktree path) en cada prompt y exigir al subagente trabajar dentro de el. Subagentes NO deben tocar el repo principal `$HOME/fn_registry/`.
Patron prompt:
```
Working dir: <WT_ROOT> # NO /home/lucas/fn_registry
Working dir: <WT_ROOT> # NO $HOME/fn_registry
Branch: auto/<issue_id>
Repo principal (solo lectura para registry.db): /home/lucas/fn_registry
Repo principal (solo lectura para registry.db): $HOME/fn_registry
...
```
@@ -346,7 +346,7 @@ Cada `progress_json` entry:
|---|---|---|
| `task_runs` no existe | migration 006 no aplicada | abortar pre-condicion 1 |
| `worktree add` falla con "already exists" | branch o dir previo no limpiado | `git worktree prune` + `git branch -D auto/<id>`, reintentar |
| Subagente toca `/home/lucas/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
| Subagente toca `$HOME/fn_registry/` en vez de worktree | prompt sin `WT_ROOT` explicito | rebriefing con working dir explicito |
| `master` desincronizado con origin | falta `git pull` | abortar pre-condicion 3 |
| Loop infinito (mismo fail siempre) | watchdog ausente o desactivado | watchdog OBLIGATORIO, no skipear |
| Subagente devuelve output ambiguo | prompt insuficiente | rebriefing con paths/IDs explicitos |
+20 -20
View File
@@ -40,10 +40,10 @@ apps/{app_name}/
```bash
# Listar todas las apps
ls -d /home/lucas/fn_registry/apps/*/
ls -d $HOME/fn_registry/apps/*/
# Verificar que cada app tiene app.md
for app in /home/lucas/fn_registry/apps/*/; do
for app in $HOME/fn_registry/apps/*/; do
name=$(basename "$app")
echo "=== $name ==="
[ -f "$app/app.md" ] && echo " app.md: OK" || echo " app.md: FALTA"
@@ -82,8 +82,8 @@ sqlite3 "$APP_DB" "SELECT * FROM schema_migrations ORDER BY version;" 2>/dev/nul
**Si faltan tablas**, aplicar migraciones:
```bash
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
```
### 3. Integridad de Entities
@@ -96,7 +96,7 @@ sqlite3 "$APP_DB" "SELECT id, name, type_ref, status, domain, source FROM entiti
# Validar que type_ref existe en registry.db
sqlite3 "$APP_DB" "SELECT DISTINCT type_ref FROM entities;" | while read ref; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM types WHERE id = '$ref';")
if [ -z "$EXISTS" ]; then
echo "ERROR: type_ref '$ref' no existe en registry.db"
fi
@@ -129,7 +129,7 @@ sqlite3 "$APP_DB" "SELECT r.id, r.name, r.to_entity FROM relations r WHERE r.to_
# Validar que 'via' referencia una funcion/pipeline del registry
sqlite3 "$APP_DB" "SELECT DISTINCT via FROM relations WHERE via != '';" | while read via; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$via';")
if [ -z "$EXISTS" ]; then
echo "ERROR: relation.via '$via' no existe en registry.db"
fi
@@ -156,7 +156,7 @@ sqlite3 "$APP_DB" "SELECT id, pipeline_id, status, started_at, duration_ms, reco
# Validar que pipeline_id existe en registry.db
sqlite3 "$APP_DB" "SELECT DISTINCT pipeline_id FROM executions;" | while read pid; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$pid';")
if [ -z "$EXISTS" ]; then
echo "ERROR: pipeline_id '$pid' no existe en registry.db"
fi
@@ -230,7 +230,7 @@ sqlite3 "$APP_DB" "SELECT id, version, lang, algebraic, snapped_at FROM types_sn
# Comparar con registry.db — detectar snapshots desactualizados
sqlite3 "$APP_DB" "SELECT id, version FROM types_snapshot;" | while IFS='|' read id ver; do
REG_VER=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
REG_VER=$(sqlite3 $HOME/fn_registry/registry.db "SELECT version FROM types WHERE id = '$id';")
if [ -z "$REG_VER" ]; then
echo "WARN: snapshot '$id' ya no existe en registry.db"
elif [ "$ver" != "$REG_VER" ]; then
@@ -252,14 +252,14 @@ done
```bash
# Verificar que la app esta en registry.db
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, domain, entry_point, dir_path FROM apps WHERE name = '{app_name}';"
# Verificar que uses_functions del app.md coincide con lo indexado
sqlite3 /home/lucas/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
sqlite3 $HOME/fn_registry/registry.db "SELECT uses_functions FROM apps WHERE name = '{app_name}';"
# Verificar que todas las funciones referenciadas existen
sqlite3 /home/lucas/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
EXISTS=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
sqlite3 $HOME/fn_registry/registry.db "SELECT f.value FROM apps, json_each(apps.uses_functions) f WHERE apps.name = '{app_name}';" | while read fid; do
EXISTS=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM functions WHERE id = '$fid';")
if [ -z "$EXISTS" ]; then
echo "ERROR: app usa funcion '$fid' que no existe en registry"
fi
@@ -273,7 +273,7 @@ done
Patron para auditar TODAS las apps de una vez:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
echo "========================================="
echo "AUDITORIA DE APPS — fn-recopilador"
@@ -327,7 +327,7 @@ for app_dir in apps/*/; do
[ "$ERROR_LOGS" -gt 0 ] 2>/dev/null && echo " [WARN] $ERROR_LOGS logs de error"
# 9. App indexada en registry.db
INDEXED=$(sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
INDEXED=$(sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = '$APP_NAME';" 2>/dev/null)
[ -n "$INDEXED" ] && echo " [OK] Indexada en registry.db" || echo " [WARN] NO indexada en registry.db"
done
@@ -393,25 +393,25 @@ echo "========================================="
El recopilador puede sugerir o ejecutar estas reparaciones:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Aplicar migraciones faltantes
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
# Actualizar snapshot desactualizado
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot update --db apps/{app_name}/operations.db --id "TYPE_ID"
# Verificar snapshots
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops snapshot check --db apps/{app_name}/operations.db
# Evaluar assertions pendientes
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops assertion eval --db apps/{app_name}/operations.db --entity-id "ENTITY_ID"
# Re-indexar para que la app aparezca en registry.db
./fn index
# Ver grafo de la app (util para diagnostico visual)
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops graph --db apps/{app_name}/operations.db
```
---
+13 -13
View File
@@ -38,13 +38,13 @@ Antes de crear nada, recopilar contexto:
```bash
# Buscar funciones relevantes por descripcion
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:TERMINO* OR name:TERMINO*') ORDER BY name;"
# Buscar apps similares
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, name, lang, description, uses_functions FROM apps WHERE id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Verificar que el nombre no esta tomado
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id FROM apps WHERE name = 'NOMBRE';"
```
4. **Presentar plan al usuario** antes de ejecutar:
@@ -79,7 +79,7 @@ Usar el Agent tool con `subagent_type: "fn-constructor"` pasando:
Despues de que fn-constructor termine, verificar que todo se indexo:
```bash
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
# Verificar cada funcion creada
./fn show {id_de_cada_funcion}
```
@@ -91,7 +91,7 @@ cd /home/lucas/fn_registry && ./fn index
### Estructura base (todos los lenguajes)
```bash
mkdir -p /home/lucas/fn_registry/apps/{app_name}
mkdir -p $HOME/fn_registry/apps/{app_name}
```
### app.md (OBLIGATORIO — siempre primero)
@@ -143,7 +143,7 @@ build/
**Go (CLI/TUI):**
```bash
cd /home/lucas/fn_registry/apps/{app_name}
cd $HOME/fn_registry/apps/{app_name}
go mod init fn_registry/apps/{app_name}
# Crear main.go, app/, config/, views/ segun necesidad
```
@@ -151,7 +151,7 @@ go mod init fn_registry/apps/{app_name}
**Go (Wails — desktop con UI):**
```bash
# Usar scaffold del registry
cd /home/lucas/fn_registry
cd $HOME/fn_registry
./fn run scaffold_wails_app -- --name {app_name} --dir apps/{app_name}
```
@@ -165,20 +165,20 @@ cd /home/lucas/fn_registry
```bash
# Crear main.sh con source a funciones del registry
# Pattern: source "$REGISTRY_ROOT/bash/functions/{domain}/{func}.sh"
chmod +x /home/lucas/fn_registry/apps/{app_name}/main.sh
chmod +x $HOME/fn_registry/apps/{app_name}/main.sh
```
### Inicializar operations.db
```bash
cd /home/lucas/fn_registry
FN_REGISTRY_ROOT=/home/lucas/fn_registry ./fn ops init apps/{app_name}
cd $HOME/fn_registry
FN_REGISTRY_ROOT=$HOME/fn_registry ./fn ops init apps/{app_name}
```
### Indexar en registry.db
```bash
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
# Verificar
sqlite3 registry.db "SELECT id, name, lang, domain FROM apps WHERE name = '{app_name}';"
```
@@ -241,7 +241,7 @@ Usar el Agent tool con `subagent_type: "gitea"` pasando:
```bash
# 1. Crear repo en Gitea (via API)
# 2. Inicializar git en la app
cd /home/lucas/fn_registry/apps/{app_name}
cd $HOME/fn_registry/apps/{app_name}
git init
git add -A
git commit -m "Initial commit: {app_name} — {descripcion}"
@@ -256,7 +256,7 @@ git push -u origin master
**Despues de publicar**, actualizar el `repo_url` en app.md y re-indexar:
```bash
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
```
---
+59 -22
View File
@@ -1,37 +1,74 @@
# /compile — Compila app C++ y la copia al escritorio de Windows
---
description: "Compila app del registry (C++ o Wails Go), copia el .exe a Desktop/apps/<app>/ y relanza en Windows. Wrapper sobre compile_cpp_app o compile_wails_app segun framework declarado en app.md."
---
Wrapper sobre el pipeline `compile_cpp_app_bash_pipelines`. Toda la lógica vive en el registry (resolver app desde CWD/arg, cross-compile MinGW, copiar exe + DLLs + assets/ + enrichers/ + runtime/ a `/mnt/c/Users/lucas/Desktop/apps/<app>/`, taskkill previo, preservar `local_files/`).
# /compile — Compila app C++ o Wails y la copia al escritorio de Windows
Wrapper sobre 2 pipelines del registry segun el framework:
- **C++ (imgui / cmake)** → `compile_cpp_app_bash_pipelines`. Cross-compile MinGW + assets/enrichers/runtime + taskkill, NO relanza.
- **Wails Go (matrix_client_pc, matrix_admin_panel, etc.)** → `compile_wails_app_bash_pipelines`. `wails build -platform windows/amd64` con `-tags goolm` si E2EE + taskkill + **RELANZA** la app tras copy.
Toda la logica vive en el registry (resolver app desde CWD/arg, build, deploy con preservacion de `local_files/`).
## Dispatch
```bash
cd /home/lucas/fn_registry
./fn run compile_cpp_app "$ARGUMENTS"
cd $HOME/fn_registry
# Detecta framework via wails.json o CMakeLists.txt en el dir del app
APP="$ARGUMENTS"
RESOLVED=$(bash -c '
source bash/functions/infra/resolve_cpp_app_dir.sh
resolve_cpp_app_dir "'"$APP"'"
' 2>/dev/null) || true
APP_DIR="$(echo "$RESOLVED" | cut -f2)"
if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/wails.json" ]; then
./fn run compile_wails_app "$ARGUMENTS"
elif [ -n "$APP_DIR" ] && [ -f "$APP_DIR/CMakeLists.txt" ]; then
./fn run compile_cpp_app "$ARGUMENTS"
else
echo "ERROR: no se detecto framework (falta wails.json o CMakeLists.txt en $APP_DIR)" >&2
exit 1
fi
```
## Argumento
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`).
`$ARGUMENTS` — opcional. Nombre de app (ej: `chart_demo`, `matrix_client_pc`).
- Sin argumento: deduce desde `pwd` si estás dentro de `cpp/apps/<X>/` o `projects/*/apps/<X>/`.
- Si no se puede deducir y no se pasa argumento, el pipeline lista las apps disponibles en stderr y aborta.
- Sin argumento: deduce desde `pwd` si estas dentro de `cpp/apps/<X>/`, `apps/<X>/` o `projects/*/apps/<X>/`.
- Si no se puede deducir y no se pasa argumento, lista las apps disponibles en stderr y aborta.
## Qué hace el pipeline
## Que hace el pipeline (C++)
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>` desde arg o CWD.
2. Verifica `CMakeLists.txt` en el dir resuelto.
3. `build_cpp_windows_bash_infra <app>` — cross-compila el target específico con `cpp/build/windows/` (configura toolchain `mingw-w64.cmake` la primera vez).
1. `resolve_cpp_app_dir_bash_infra` — resuelve `<app_name>` y `<dir absoluto>`.
2. Verifica `CMakeLists.txt`.
3. `build_cpp_windows_bash_infra <app>` — cross-compila con MinGW.
4. `deploy_cpp_exe_to_windows_bash_infra <app> <dir>`:
- `taskkill.exe /IM <app>.exe /F` (pre-autorizado).
- Copia `<app>.exe` + DLLs al top-level de `Desktop/apps/<app>/`.
- rsync `cpp/build/windows/apps/<app>/assets/` `Desktop/apps/<app>/assets/`.
- rsync `<app_dir>/enrichers/``assets/enrichers/` si existe.
- Si `app.md` declara `python_runtime: true`, regenera `runtime/` con `tools/freeze_python_runtime.sh` y rsync a `assets/runtime/`.
- Copia `gx-cli`/`gx-cli.exe` si existen.
- **NUNCA** toca `local_files/` (estado del usuario).
5. Imprime `ls -lh` del `.exe` final.
- `taskkill.exe /IM <app>.exe /F`.
- Copia `<app>.exe` + DLLs.
- rsync `assets/`, `enrichers/`, `runtime/` (si aplica).
- Preserva `local_files/`.
- **NO** relanza.
## Que hace el pipeline (Wails)
1. `resolve_cpp_app_dir_bash_infra` (reusado — sirve para Wails apps tambien).
2. Verifica `wails.json` + `go.mod`.
3. Detecta `-tags goolm` automaticamente (grep `matrix_crypto_init` en `app.md` o `build:tags` en `wails.json`).
4. `wails build -platform windows/amd64 [-tags goolm]`.
5. `deploy_wails_exe_to_windows_bash_infra <app> <dir>`:
- `taskkill.exe /IM <app>.exe /F`.
- Copia `<app>.exe` (+ `appicon.ico` si existe).
- **Relanza** via `cmd.exe /c start "" <app>.exe`.
- Preserva `local_files/`.
## Notas
- Solo target Windows hoy. Android / Linux quedan fuera (Linux ya lo da `cpp/build/`).
- Solo target Windows hoy. Linux ya lo da `wails build` / `cpp/build/` nativo.
- Variables override-ables: `BUILD_WIN`, `WIN_DESKTOP_APPS`, `FN_REGISTRY_ROOT`.
- Si la app no está registrada en `cpp/CMakeLists.txt`, `cmake --build --target <app>` falla. Registrar siguiendo `.claude/rules/cpp_apps.md` §5.
- Para tocar la lógica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,deploy_cpp_exe_to_windows,compile_cpp_app}.sh`, no este wrapper.
- Si la app C++ no esta registrada en `cpp/CMakeLists.txt`, el build falla — registrar siguiendo `.claude/rules/cpp_apps.md` §5.
- Si la app Wails falla build con `no required module provides package`, correr `go mod tidy` en el dir del app primero.
- Para tocar la logica: editar `bash/functions/{infra,pipelines}/{resolve_cpp_app_dir,build_cpp_windows,deploy_{cpp,wails}_exe_to_windows,compile_{cpp,wails}_app}.sh`, no este wrapper.
+6 -6
View File
@@ -23,8 +23,8 @@ Si `$ARGUMENTS` no empieza por `modify`, es create. Si trae `<name>`, lo usas co
### Paso 0 — verificar que no existe
```bash
test -d "/home/lucas/fn_registry/apps/<name>" \
|| ls /home/lucas/fn_registry/projects/*/apps/<name> 2>/dev/null
test -d "$HOME/fn_registry/apps/<name>" \
|| ls $HOME/fn_registry/projects/*/apps/<name> 2>/dev/null
```
Si existe en cualquier ubicacion: **abortar** y sugerir `/cpp-app modify <name>`. NO sobreescribir.
@@ -42,7 +42,7 @@ Regla dura `cpp_apps.md`: description + icon.phosphor + icon.accent SIEMPRE junt
5. **icon.phosphor** glyph name. Antes de preguntar, ofrece busqueda:
```bash
ls /home/lucas/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
ls $HOME/fn_registry/sources/phosphor-core/assets/fill/ | grep -i "<keyword>"
```
Sugiere 3-5 candidatos basados en `description`. Default segun domain: `gfx`->`palette`, `tui`->`terminal`, `tools`->`wrench`, `infra`->`gear`, `finance`->`chart-line-up`, `datascience`->`graph`, `cybersecurity`->`shield`.
6. **icon.accent** hex `#rrggbb` (palette select):
@@ -122,7 +122,7 @@ Mostrar bloque YAML completo del `app.md` que se va a generar + flags del scaffo
Una vez confirmado:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# 1. Scaffolder
./fn run init_cpp_app <name> \
@@ -178,7 +178,7 @@ cd /home/lucas/fn_registry
```bash
# Buscar apps/<name>/ o projects/*/apps/<name>/
sqlite3 /home/lucas/fn_registry/registry.db \
sqlite3 $HOME/fn_registry/registry.db \
"SELECT id, dir_path FROM apps WHERE name='<name>' AND lang='cpp';"
```
@@ -211,7 +211,7 @@ Para cada cambio: usa `Edit` sobre los archivos correspondientes. NUNCA `Write`
```bash
# Siempre
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
# Si toco icon.* -> regenerar appicon
./fn run generate_app_icon "<phosphor>" "<accent>" "<dir>/appicon.ico"
+12 -12
View File
@@ -38,19 +38,19 @@ Consultar `registry.db` para encontrar funciones existentes relevantes y evitar
```bash
# Buscar funciones similares por nombre y descripcion (OBLIGATORIO — usar multiples terminos)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, lang, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:TERMINO1* OR description:TERMINO1* OR name:TERMINO2* OR description:TERMINO2*') ORDER BY name;"
# Buscar tipos relacionados
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, lang, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:TERMINO* OR description:TERMINO*') ORDER BY name;"
# Funciones del dominio objetivo
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, signature, description FROM functions WHERE domain = 'DOMINIO' AND lang = 'LANG' ORDER BY name;"
# Tipos del dominio objetivo
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'DOMINIO' ORDER BY name;"
# Funciones que podrian componerse (misma firma de retorno)
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, purity, signature FROM functions WHERE returns LIKE '%TIPO%' OR signature LIKE '%TIPO%' ORDER BY name;"
```
**Clasificar resultados en:**
@@ -103,7 +103,7 @@ Para cada batch del plan, lanzar agentes `fn-constructor` **en paralelo** (un ag
Usar el Agent tool con `subagent_type: "fn-constructor"` pasando un prompt completo con:
```
Crea la siguiente funcion para el registry fn_registry en /home/lucas/fn_registry:
Crea la siguiente funcion para el registry fn_registry en $HOME/fn_registry:
Funcion: {nombre}
Kind: {kind}
@@ -149,7 +149,7 @@ Despues de que TODOS los fn-constructor terminen:
```bash
# Indexar todo de una vez
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
```
Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
@@ -166,7 +166,7 @@ Si el indexer reporta errores, corregirlos antes de continuar. Errores comunes:
```bash
# Verificar cada funcion creada
cd /home/lucas/fn_registry
cd $HOME/fn_registry
./fn show {id_de_cada_funcion}
# Verificar que no hay funciones sin params_schema
@@ -178,7 +178,7 @@ cd /home/lucas/fn_registry
Para cada funcion con tests, ejecutar:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Go
CGO_ENABLED=1 go test -tags fts5 -v -run TestNombreDelTest ./functions/{domain}/
@@ -197,13 +197,13 @@ bash bash/functions/{domain}/{nombre}_test.sh
```bash
# Verificar que todas las funciones nuevas estan en la BD
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, kind, purity, tested FROM functions WHERE id IN ('id1','id2','id3') ORDER BY name;"
# Verificar que los tests estan indexados
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, function_id, name FROM unit_tests WHERE function_id IN ('id1','id2','id3') ORDER BY function_id;"
# Verificar dependencias
sqlite3 /home/lucas/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
sqlite3 $HOME/fn_registry/registry.db "SELECT id, uses_functions, uses_types FROM functions WHERE id IN ('id1','id2','id3') AND uses_functions != '[]';"
```
### 6.4 Si algo fallo
+3 -3
View File
@@ -45,7 +45,7 @@ Antes de escribir nada, repasar la conversacion y juntar:
2. **Cambios concretos** desde git:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
git status --short
git diff --stat
git log --since="6 hours ago" --oneline
@@ -70,7 +70,7 @@ Si el material es solo conversacion exploratoria sin artefactos tocados, ir dire
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
# Funcion / tipo
sqlite3 registry.db "SELECT id, file_path FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NAME* OR description:NAME*');"
@@ -180,7 +180,7 @@ Para cada `.md` identificado:
Si los cambios de la sesion incluyen creacion de funciones/tipos/apps/projects/analysis/vaults o modificacion de frontmatter:
```bash
cd /home/lucas/fn_registry && ./fn index
cd $HOME/fn_registry && ./fn index
```
Y verificar:
+1 -1
View File
@@ -17,7 +17,7 @@ Suite ya instalada en `cpp/vendor/imgui_test_engine/`. Integracion en framework:
### 1. Resolver app y directorio
```bash
ROOT=/home/lucas/fn_registry
ROOT=$HOME/fn_registry
ARGS="$ARGUMENTS"
APP_ARG="${ARGS%% *}" # primera palabra
FLOW_DESC="${ARGS#* }" # resto (puede coincidir con APP_ARG si solo hay una palabra)
+1 -1
View File
@@ -17,7 +17,7 @@ Wrapper sobre `append_diary_entry_bash_infra`. La función del registry maneja t
2. **Llamar la función del registry**:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
source bash/functions/infra/append_diary_entry.sh
append_diary_entry "<TITULO>" "$(cat <<'EOF'
<CUERPO>
+1 -1
View File
@@ -50,7 +50,7 @@ Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decid
### 1. AUDIT — ¿estoy siendo registrado?
```bash
ROOT="/home/lucas/fn_registry"
ROOT="$HOME/fn_registry"
MON="$ROOT/projects/fn_monitoring/apps/call_monitor/operations.db"
# Pre-condiciones
+1 -1
View File
@@ -3,7 +3,7 @@
Wrapper sobre el pipeline `full_git_pull_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
./fn run full_git_pull_bash_pipelines
```
+1 -1
View File
@@ -3,7 +3,7 @@
Wrapper sobre el pipeline `full_git_push_bash_pipelines`. Toda la lógica vive en el registry. Este comando solo ejecuta:
```bash
cd /home/lucas/fn_registry
cd "${FN_REGISTRY_ROOT:-$HOME/fn_registry}"
./fn run full_git_push_bash_pipelines "$ARGUMENTS"
```
+1 -1
View File
@@ -3,7 +3,7 @@
Wrapper sobre el pipeline `init_cpp_app_bash_pipelines`. Genera la estructura canonica que cumple `cpp/PATTERNS.md` y `.claude/rules/cpp_apps.md` (main.cpp con `cfg.about/log/panels`, sin `app_menubar` manual, dockspace via framework), registra la app en `cpp/CMakeLists.txt`, crea repo Gitea `dataforge/<name>` y ejecuta `fn index`.
```bash
cd /home/lucas/fn_registry
cd $HOME/fn_registry
./fn run init_cpp_app $ARGUMENTS
```
+1 -1
View File
@@ -17,7 +17,7 @@ Si vacio: detectar app desde `pwd` (si estas dentro de `apps/<X>/` o `projects/*
### 1. Resolver app objetivo
```bash
ROOT=/home/lucas/fn_registry
ROOT=$HOME/fn_registry
ARG="$ARGUMENTS"
if [ -z "$ARG" ]; then
+1
View File
@@ -38,3 +38,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
| 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. |
+1 -1
View File
@@ -16,7 +16,7 @@
```bash
# 1. Agente trabaja en worktree del repo padre
cd /home/lucas/fn_registry/worktrees/<slug>
cd $HOME/fn_registry/worktrees/<slug>
# 2. Scaffold la app via pipeline canonico
./fn run init_cpp_app <name> # apps C++
+131
View File
@@ -0,0 +1,131 @@
# DoD Quality Triada
**Definition of Done no es un checkbox que se marca a mano. Es un contrato de calidad con 3 capas obligatorias + evidencia ejecutable + uso real >=7 dias.**
Aplica a todos los `dev/flows/` y, por extension, a issues que cierran capabilities user-facing (`dev/issues/`). El registry mismo (funciones puras, tipos) queda exento: su DoD vive en sus tests unitarios.
---
## Por que existe esta regla
El antipatron a eliminar: "tarea hecha porque pase los tests una vez". Despues:
- El flow funciona en `home-wsl` pero falla en `pc-aurgi`.
- El error path declarado nunca se ejercito y cuando ocurre en produccion no esta manejado.
- El dashboard de observabilidad lleva 30 dias sin abrirse.
- El proceso muere cada noche y nadie lo ve hasta que el operador intenta usarlo.
- El approval flow se salta porque "para test es mas comodo".
Resultado: deuda invisible. Cada flow "done" se rompe al primer uso real, el operador pierde confianza en el sistema, y el bucle reactivo no detecta nada porque la telemetria esta verde (los tests sintenticos pasan).
DoD Quality Triada cambia las reglas: cerrar = probar comportamiento + sobrevivir uso real, no = compilar verde.
---
## Las 3 capas
### Capa 1: Mecanica (pre-requisito, NO es DoD por si misma)
Compilar verde, tests verdes, indexado limpio, `fn doctor` verde, `uses_functions` sin drift.
**Regla**: la mecanica NO basta. Es la base para empezar a probar comportamiento. Si te quedas aqui, el flow no esta hecho.
### Capa 2: Cobertura de comportamiento
Cada escenario relevante con prueba ejecutable y assert material. NO smoke "el comando no peto". Minimo:
- **1 golden path** — el caso feliz documentado con assert sobre output concreto.
- **>=2 edge cases** — inputs limite, estados raros, condiciones de borde.
- **>=1 error path** — fallo provocado intencionalmente, manejado y observable (sin crash, sin silent-fail).
Formato canonico (tabla en `## Definition of Done` del flow/issue):
```markdown
| Escenario | Tipo | Comando / evidencia | Resultado esperado |
|---|---|---|---|
| Golden: <desc> | unit / e2e | `<cmd>` | <output concreto> |
| Edge 1: <desc> | unit / e2e | `<cmd>` | <comportamiento concreto> |
| Error 1: <desc> | e2e | `<cmd que rompe>` | <fallo manejado, no crash> |
```
Cuando aplique, cada fila genera un `e2e_check` en el `app.md` correspondiente (issue 0068). `fn-analizador` los corre periodicamente y deja entry en `e2e_runs`.
### Capa 3: Vida util validada
El flow no esta hecho hasta que sobrevive **uso real durante >=7 dias** sin romperse silenciosamente. Cada metrica con umbral medible y dashboard observable.
Formato canonico:
```markdown
| Metrica | Umbral | Donde se observa | Ventana |
|---|---|---|---|
| <metrica 1> | `>=N` | `<dashboard URL / app panel>` | 7 dias |
| crashes | `0` | `journalctl -u <unit>` | 7 dias |
| huecos audit chain | `0` | `cmd: <verify>` | continuo |
```
Reglas:
- Metricas NO se auto-reportan; las lee el operador del dashboard real.
- Si el dashboard no existe o no se ha abierto en 30 dias, el item se invalida.
- Crashes del proceso = 0, huecos en audit = 0, error_rate < umbral declarado.
### Capa transversal: User-facing reforzado
- Surface concreta NO BD ni log (UI app, room Matrix, dashboard, archivo en vault).
- Usage real: humano usa en su PC, su contexto, >=N veces variadas en >=7 dias.
- Variado: >=3 capabilities/casos distintos (no solo "abre dashboard y mira").
- Onboarding: parrafo en `## Notas` que explica como usar la cosa sin leer el flow.
- Latencia medida (no declarada).
---
## Reglas duras para marcar `status: done`
`/flow done` (y por extension cierres de issues user-facing) DEBE rechazar el cierre si:
1. Falta cualquiera de las 3 capas (mecanica + cobertura + vida).
2. Cobertura tiene <1 golden, <2 edge, o <1 error path con evidencia.
3. Vida util tiene tabla vacia o sin dashboard observable real.
4. User-facing usage real <7 dias o <N usos declarados.
5. Cualquier anti-criterio marcado como cierto.
6. `## Notas` sin parrafo onboarding.
7. Algun item de DoD sin comando/URL/log query asociado — solo texto.
Hoy parte de esta validacion es manual (revision humana del operador). La validacion programatica vive en `audit_dod_schema_go_infra` (issue 0114) + `fn doctor dod` y se ampliara hasta cubrir las 3 capas (TBD).
---
## Antipatrones (invalidan la DoD aunque los checkboxes esten verdes)
| Antipatron | Por que es malo | Sustituir por |
|---|---|---|
| Marcar `done` porque pasa una vez | Tarea "hecha" se rompe al primer uso real | Capa 3: >=7 dias de uso real |
| Checkbox sin evidencia ejecutable | DoD se convierte en placebo | Cada item con `cmd:` / URL / log query |
| Test que solo verifica camino feliz | El error path es donde se pierden datos | Capa 2: >=1 error path ejercitado |
| Observabilidad declarada pero dashboard no abierto en 30 dias | Telemetria muerta = ceguera | Capa 3: dashboard real, operador lo abre |
| "Repetible 3 veces consecutivas" con BD efimera | No prueba sobre datos reales acumulados | Capa 3: PC real del operador, datos vivos |
| Approval saltado en algun camino | Security gate roto pero invisible | Anti-criterio explicito: `audit_log` lo prueba |
| Error path manejado solo "en teoria" | Cuando ocurra en produccion el manejo no existe | Capa 2: entry real en `e2e_runs` o audit |
| Solo-en-mi-PC | Falla en otra maquina del operador | Anti-criterio explicito, probar >=2 PCs |
| Self-test que retorna `pass` sin asserts materiales | False positive sistemico | Asserts sobre output concreto, no exit-0 |
| Silent-fail (proceso muere sin alerta) | Operador no se entera hasta intentar usar | Capa 3: crashes=0 + alerta visible |
---
## Relacion con otras reglas
- [[e2e_validation]] — los escenarios de Capa 2 cuando aplican a apps se materializan como `e2e_checks` en `app.md`. `fn-analizador` (fase 4 del bucle reactivo) los corre.
- [[registry_calls]] — la evidencia de uso (`call_monitor.calls`) alimenta los umbrales de Capa 3.
- [[function_growth_and_self_docs]] — cada funcion del registry tiene su propio contrato self-doc (Ejemplo + Cuando usarla + Gotchas). DoD del flow NO sustituye al self-doc de la funcion; lo complementa para el nivel sistema.
- [[autonomous_loop]] — `fn-orquestador` autonomo NO puede marcar `done` sin que se cumplan las 3 capas. Su criterio de convergencia incluye DoD Quality.
- [[apps_tbd]] — TBD garantiza master desplegable; DoD garantiza que lo desplegado funciona en uso real.
---
## TL;DR
1. **Mecanica** = compilar verde (pre-requisito, NO suficiente).
2. **Cobertura** = golden + >=2 edge + >=1 error path con evidencia ejecutable.
3. **Vida util** = >=7 dias de uso real sin romper silenciosamente, dashboard observable abierto.
4. **User-facing reforzado** = humano usa en PC real, >=N veces variadas.
5. **Anti-criterios** invalidan la DoD aunque todo este verde.
6. Sin evidencia ejecutable (cmd/URL/log), NO es DoD: es deseo.
+4
View File
@@ -3,6 +3,10 @@
"registry": {
"command": "./apps/registry_mcp/registry_mcp",
"args": ["--enable-run", "--enable-write"]
},
"jupyter": {
"command": "bash",
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
}
}
}
+22
View File
@@ -0,0 +1,22 @@
[2026-05-22 23:18:14.872] [INFO] app start: Agents Dashboard
[2026-05-22 23:24:12.811] [INFO] app start: Agents Dashboard
[2026-05-22 23:24:14.628] [INFO] [connect] testing https://agents.organic-machine.com...
[2026-05-22 23:24:14.758] [INFO] [connect] OK
[2026-05-22 23:24:14.765] [INFO] [db] base_url saved
[2026-05-22 23:24:14.765] [INFO] [fetch_agents] starting
[2026-05-22 23:24:14.766] [INFO] [fetch_agents] requesting https://agents.organic-machine.com/agents
[2026-05-22 23:24:14.903] [INFO] [fetch_agents] response status=200 err= body_len=3146
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] parsed 11 rows
[2026-05-22 23:24:14.904] [INFO] [fetch_agents] done
[2026-05-22 23:24:14.910] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:27:07.469] [INFO] app start: Agents Dashboard
[2026-05-22 23:27:08.242] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:27:36.670] [INFO] app start: Agents Dashboard
[2026-05-22 23:27:37.446] [INFO] [agents_panel] render n_rows=11 cells=121 specs=11
[2026-05-22 23:28:07.068] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:03.025] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:38.605] [INFO] app start: Agents Dashboard
[2026-05-22 23:30:48.267] [INFO] app start: Agents Dashboard
[2026-05-22 23:40:58.931] [INFO] app start: Agents Dashboard
[2026-05-22 23:41:16.455] [INFO] app start: Agents Dashboard
[2026-05-22 23:42:35.646] [INFO] app start: Agents Dashboard
@@ -0,0 +1,80 @@
---
name: chrome_load_extensions
kind: function
lang: bash
domain: browser
version: "1.0.0"
purity: impure
signature: "chrome_load_extensions [--port N] [--profile DIR] --ext PATH [--ext PATH ...] [--proxy URL] [--url URL]"
description: "Lanza Chrome con extensiones unpacked via --load-extension (WSL2→Windows chrome.exe, paths traducidos, join sin echo, setsid anti-exit-144). OJO: --load-extension SOLO funciona en Chrome for Testing/Chromium/Dev. En Chrome STABLE 138+ esta DESACTIVADO (feature DisableLoadExtensionCommandLineSwitch + bloqueo duro en 148) y carga 0 extensiones aunque el cmdline sea correcto. Para Chrome stable usar install via Web Store (1-clic, persiste en perfil) o enterprise policy ExtensionInstallForcelist (requiere HKLM/HKCU Policies escribible — denegado en maquinas gestionadas)."
tags: [chrome, cdp, browser, extensions, wsl2, navegator]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
params:
- name: "--port N"
desc: "Puerto de remote debugging CDP. Default: 9222."
- name: "--profile DIR"
desc: "Chrome user-data-dir. Acepta ruta Windows (C:\\...) o ruta WSL/Linux (se traduce via wslpath -w). Default: C:\\Users\\<USERNAME>\\AppData\\Local\\fn-chrome-cdp-profile (WSL2) o /tmp/fn-chrome-cdp-profile (Linux nativo)."
- name: "--ext PATH"
desc: "Ruta a un directorio de extensión unpacked. Repetible. Acepta ruta Windows (se pasa intacta) o ruta WSL/Linux (se traduce via wslpath -w). Obligatorio al menos uno."
- name: "--proxy URL"
desc: "Proxy opcional, ej. http://127.0.0.1:8889. Agrega --proxy-server=URL a Chrome."
- name: "--url URL"
desc: "URL inicial opcional para abrir con --new-window."
output: "PID del proceso Chrome lanzado (stdout). Mensajes de estado en stderr. CDP listo en 127.0.0.1:<port>."
file_path: "bash/functions/browser/chrome_load_extensions.sh"
---
## Ejemplo
```bash
source bash/functions/browser/chrome_load_extensions.sh
chrome_load_extensions \
--port 9222 \
--profile 'C:\Users\lucas\AppData\Local\fn-chrome-cdp-profile' \
--ext 'C:\Users\lucas\hls-dl-ext' \
--ext 'C:\Users\lucas\ubol' \
--proxy http://127.0.0.1:8889 \
--url https://www.gnularetro.cc/
```
Sin proxy ni URL, sólo extensiones:
```bash
source bash/functions/browser/chrome_load_extensions.sh
pid=$(chrome_load_extensions \
--ext '/home/lucas/dev/hls-dl-ext' \
--ext '/home/lucas/dev/ubol')
# Paths WSL traducidos automáticamente a Windows.
# CDP listo en 127.0.0.1:9222.
echo "Chrome PID: $pid"
```
## Cuando usarla
Cuando necesites Chrome CDP con extensiones unpacked cargadas (HLS downloader, uBlock Origin, extensiones en desarrollo) y `chrome_launch_go_browser` no sirve porque hardcodea `--disable-extensions`. WSL2→Windows. Ideal para sesiones de navegator con proxy + extensión activa.
## Gotchas
- **MUERTO en Chrome STABLE 138+ (validado 2026-05-30, Chrome 148)**: `--load-extension` NO carga nada en el canal stable, ni con `--disable-extensions-except` ni con `--disable-features=DisableLoadExtensionCommandLineSwitch`. `chrome://version` muestra el flag correcto pero `chrome://extensions` sale vacío. Google lo bloqueó duro en stable. La función SOLO sirve en **Chrome for Testing / Chromium / Dev/Canary**, donde el switch sigue activo. Para stable: ver opciones abajo.
- **Instalar en Chrome STABLE (las que SÍ funcionan)**:
1. **Web Store 1-clic** — abre la página del store en el perfil CDP, el humano da "Añadir a Chrome". Persiste en el perfil para siempre (futuros lanzamientos ya con la extensión, sin flags). El popup de confirmación es UI del navegador (no DOM) → NO es CDP-clickable, requiere gesto humano. Único método no-admin que persiste por-perfil.
2. **Enterprise policy** `ExtensionInstallForcelist` (HKCU/HKLM `\Software\Policies\Google\Chrome`) — force-install sin clic desde el store, browser-wide. El key `Policies\Google\Chrome` puede dar "Access denied" al escribir (visto 2026-05-30 incluso en máquina personal vía reg.exe/PowerShell desde WSL — Chrome/Windows protege el subárbol Policies). Si funciona, requiere relanzar Chrome para que descargue del store. Método global (afecta todos los perfiles).
3. Extensiones **unpacked custom** (no en store, ej. un HLS downloader propio) en stable: no hay vía no-admin. Empaquetar a CRX + self-host `update_url` + policy, o usar Chrome for Testing. A menudo innecesario si la lógica vive fuera (ej. `grab_stream.py` descarga sin extensión).
- **Combo flags (solo Chrome for Testing/dev)**: requiere AMBOS `--load-extension=p1,p2` Y `--disable-extensions-except=p1,p2` juntos + `--disable-features=DisableLoadExtensionCommandLineSwitch`. **NUNCA `--disable-extensions`** (desactiva todo).
- **join sin `echo`**: rutas Windows `C:\Users\...` tienen `\U`; el `echo` de zsh (o sh con xpg_echo) lo interpreta como escape unicode y trunca la ruta a `C:`. La función usa acumulador `+=`, no `echo`. Verificable en `chrome://version` (debe verse el path completo, no `--load-extension=C:`).
- **exit 144 en Bash tool**: si el proceso Chrome retiene el pipe stdout, la herramienta devuelve exit 144. Esta función lanza con `setsid ... </dev/null >log 2>&1 &` + `disown` para desacoplar completamente. El log queda en `/tmp/chrome_ext_<port>.log`.
- **WSL2: traducir paths con `wslpath -w`**: los paths de `--ext` y `--profile` que sean rutas Linux se traducen automáticamente. Las rutas Windows (`C:\...`) se pasan intactas. `wslpath` debe estar disponible (estándar en WSL2 desde Windows 10 1903+).
- **Perfil ya abierto**: si Chrome ya tiene ese perfil abierto, relanzar añade una ventana extra a la misma instancia. La función detecta si CDP ya responde en el puerto y avisa por stderr, pero procede igualmente.
- **Web Store vs unpacked**: instalar extensiones desde la Web Store (un clic) persiste en el perfil sin necesidad de flags y sobrevive reinicios. Esta función es para extensiones unpacked en desarrollo o que no están en la Web Store. Si usas ambas, los flags no interfieren con las instaladas del store.
- **zsh globbing**: `--remote-allow-origins=*` está dentro de comillas en la función, no se expande. Si lo pasas desde la línea de comandos, entrecomillarlo.
- **Proxy + extensión**: si usas proxy para captura de tráfico (Burp, mitmproxy, gost), el proxy se aplica a toda la sesión Chrome, incluyendo el tráfico de las extensiones.
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# chrome_load_extensions — lanza Chrome (WSL2→Windows chrome.exe) con extensiones unpacked cargadas en un perfil CDP.
# Chrome 148+: requiere --load-extension=<paths> Y --disable-extensions-except=<same paths> juntos.
# NUNCA pasar --disable-extensions (desactiva todo, incluyendo las que quieres cargar).
chrome_load_extensions() {
local port=9222
local profile=""
local proxy=""
local url=""
local -a ext_paths=()
# --- Parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
port="$2"; shift 2 ;;
--profile)
profile="$2"; shift 2 ;;
--ext)
ext_paths+=("$2"); shift 2 ;;
--proxy)
proxy="$2"; shift 2 ;;
--url)
url="$2"; shift 2 ;;
--*)
echo "chrome_load_extensions: flag desconocido: $1" >&2; return 1 ;;
*)
# Positional = extra ext path
ext_paths+=("$1"); shift ;;
esac
done
if [[ ${#ext_paths[@]} -eq 0 ]]; then
echo "chrome_load_extensions: se requiere al menos un --ext PATH de extension unpacked" >&2
return 1
fi
# --- Detectar chrome.exe ---
local chrome_bin=""
if command -v chrome.exe &>/dev/null; then
chrome_bin="chrome.exe"
elif [[ -f "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" ]]; then
chrome_bin="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
elif [[ -f "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" ]]; then
chrome_bin="/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"
else
echo "chrome_load_extensions: chrome.exe no encontrado en PATH ni en rutas conocidas" >&2
return 1
fi
# --- Detectar WSL2 ---
local wsl2=0
if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then
wsl2=1
fi
# --- Traducir paths de extensiones a Windows si hace falta ---
local -a win_ext_paths=()
for p in "${ext_paths[@]}"; do
if [[ $wsl2 -eq 1 ]] && [[ "$p" != [A-Za-z]:\\* ]]; then
# Path Linux → traducir a Windows
local win_p
win_p=$(wslpath -w "$p" 2>/dev/null) || {
echo "chrome_load_extensions: wslpath -w '$p' falló" >&2
return 1
}
win_ext_paths+=("$win_p")
else
win_ext_paths+=("$p")
fi
done
# --- Resolver perfil ---
if [[ -z "$profile" ]]; then
# Default: perfil canónico fn-chrome-cdp-profile en Windows
local win_user="${USERNAME:-${USER:-lucas}}"
if [[ $wsl2 -eq 1 ]]; then
profile="C:\\Users\\${win_user}\\AppData\\Local\\fn-chrome-cdp-profile"
else
profile="/tmp/fn-chrome-cdp-profile"
fi
elif [[ $wsl2 -eq 1 ]] && [[ "$profile" != [A-Za-z]:\\* ]]; then
# Path Linux del perfil → traducir a Windows
profile=$(wslpath -w "$profile" 2>/dev/null) || {
echo "chrome_load_extensions: wslpath -w '$profile' falló" >&2
return 1
}
fi
# --- Construir lista de paths separada por coma (para Chrome) ---
# Chrome usa coma como separador en --load-extension y --disable-extensions-except.
# NO usar `echo` para el join: rutas Windows como C:\Users tienen \U, y el echo de
# zsh (o sh con xpg_echo) interpreta \U como escape unicode y trunca la ruta a "C:".
# Acumulador con += y printf-safe, sin interpretacion de backslashes.
local ext_list=""
local p
for p in "${win_ext_paths[@]}"; do
ext_list+="${ext_list:+,}${p}"
done
# --- Construir args de Chrome ---
local -a args=(
"--remote-debugging-port=${port}"
"--user-data-dir=${profile}"
"--no-first-run"
"--no-default-browser-check"
"--remote-allow-origins=*"
"--load-extension=${ext_list}"
"--disable-extensions-except=${ext_list}"
# Chrome 137+ activa por defecto el feature DisableLoadExtensionCommandLineSwitch,
# que IGNORA silenciosamente --load-extension. Hay que desactivarlo o las
# extensiones unpacked no cargan (chrome://extensions sale vacio).
"--disable-features=DisableLoadExtensionCommandLineSwitch"
)
# WSL2: bind en 0.0.0.0 para que sea accesible desde la red WSL
if [[ $wsl2 -eq 1 ]]; then
args+=("--remote-debugging-address=0.0.0.0")
fi
if [[ -n "$proxy" ]]; then
args+=("--proxy-server=${proxy}")
fi
if [[ -n "$url" ]]; then
args+=("--new-window" "$url")
fi
# --- Revisar si CDP ya responde en el puerto ---
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
echo "chrome_load_extensions: CDP ya activo en puerto ${port}; lanzando ventana extra" >&2
fi
# --- Lanzar Chrome desacoplado del proceso padre ---
# setsid + redirección evita el exit 144 en el Bash tool (el pipe no queda retenido).
setsid "$chrome_bin" "${args[@]}" </dev/null >"/tmp/chrome_ext_${port}.log" 2>&1 &
local chrome_pid=$!
disown "$chrome_pid"
echo "chrome_load_extensions: Chrome lanzado PID=${chrome_pid} puerto=${port}" >&2
# --- Esperar a que CDP esté listo (hasta 15 segundos) ---
local deadline=$(( $(date +%s) + 15 ))
local ready=0
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sf --max-time 1 "http://127.0.0.1:${port}/json/version" &>/dev/null; then
ready=1
break
fi
sleep 0.5
done
if [[ $ready -eq 1 ]]; then
echo "chrome_load_extensions: CDP listo en 127.0.0.1:${port}"
else
echo "chrome_load_extensions: advertencia — CDP no respondió en 15s en puerto ${port}; Chrome puede estar iniciando lentamente" >&2
fi
echo "$chrome_pid"
}
@@ -38,7 +38,7 @@ if [[ -n "$matches" ]]; then
fi
# Escanear repo especifico
scan_secrets_in_dirty /home/lucas/fn_registry
scan_secrets_in_dirty $HOME/fn_registry
```
## Patrones detectados
@@ -22,14 +22,14 @@ params:
- name: app_name
desc: "Nombre de la app (ej: chart_demo). Se usa para localizar cpp/build/windows/apps/<app>/<app>.exe y el directorio destino Desktop/apps/<app>/."
- name: app_dir
desc: "Ruta absoluta al directorio fuente de la app (ej: /home/lucas/fn_registry/cpp/apps/chart_demo). Se usa para localizar enrichers/, runtime/ y app.md."
desc: "Ruta absoluta al directorio fuente de la app (ej: $HOME/fn_registry/cpp/apps/chart_demo). Se usa para localizar enrichers/, runtime/ y app.md."
output: "Copia archivos al escritorio de Windows. Imprime 'OK: <app> -> <dest>' en stdout. Si local_files/ existe, imprime su tamanio. Errores fatales a stderr con exit 1."
---
## Ejemplo
```bash
deploy_cpp_exe_to_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo"
deploy_cpp_exe_to_windows "chart_demo" "$HOME/fn_registry/cpp/apps/chart_demo"
# OK: chart_demo -> /mnt/c/Users/lucas/Desktop/apps/chart_demo
# Con rutas custom via env vars
@@ -55,7 +55,7 @@ Desktop/apps/<APP>/
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
## Notas
@@ -12,7 +12,7 @@ deploy_cpp_exe_to_windows() {
return 1
fi
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
@@ -0,0 +1,61 @@
---
name: deploy_wails_exe_to_windows
kind: function
lang: bash
domain: infra
version: "0.1.0"
purity: impure
signature: "deploy_wails_exe_to_windows <app_name> <app_dir>"
description: "Copia el .exe de una app Wails desde <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso anterior (taskkill /F) y relanza la app via cmd.exe. Single-binary: no copia DLLs (Webview2 nativo en SO). Preserva local_files/ si existe."
tags: ["wails", "windows", "deploy", "cross-compile", "mingw", "infra", "launch", "matrix-mas"]
params:
- name: app_name
desc: "Nombre del binario sin extension (ej. matrix_client_pc). Debe coincidir con el nombre del .exe generado por wails build."
- name: app_dir
desc: "Ruta absoluta al directorio raiz de la app, donde vive build/bin/<app>.exe. Puede estar en projects/<project>/apps/<app>/ o apps/<app>/."
output: "Imprime pasos en stderr. En stdout: ls -lh del .exe desplegado. Exit 0 si ok, exit 1 si build/bin/<app>.exe no existe o los args estan vacios."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "args vacios devuelven error con mensaje de uso"
- "app_dir inexistente devuelve exit 1"
- "build/bin exe inexistente devuelve exit 1"
test_file_path: "bash/functions/infra/deploy_wails_exe_to_windows_test.sh"
file_path: "bash/functions/infra/deploy_wails_exe_to_windows.sh"
---
## Ejemplo
```bash
source bash/functions/infra/deploy_wails_exe_to_windows.sh
# Desplegar matrix_client_pc tras wails build -platform windows/amd64
deploy_wails_exe_to_windows matrix_client_pc \
$HOME/fn_registry/projects/element_agents/apps/matrix_client_pc
```
Con override de destino:
```bash
WIN_DESKTOP_APPS=/mnt/c/Users/lucas/Desktop/apps \
deploy_wails_exe_to_windows matrix_admin_panel \
$HOME/fn_registry/projects/element_agents/apps/matrix_admin_panel
```
## Cuando usarla
Tras un `wails build -platform windows/amd64` exitoso, para desplegar el binario compilado en Windows y relanzarlo en el mismo paso. Ideal en el ciclo de iteracion rapida: compilar → desplegar → ver cambios. Equivalente a `deploy_cpp_exe_to_windows_bash_infra` pero para apps Wails (single-binary sin DLLs extras).
## Gotchas
- **taskkill /F fuerza muerte** sin permitir guardado en disco. Las apps Wails persisten estado en keyring de Windows y AppData — este kill es seguro para ellas. Si la app tuviera autosave en progreso, se perderia (aceptable en ciclos de dev).
- **UNC paths prohibidos en cmd.exe**: `cmd.exe /c start` debe ejecutarse con `cd` previo al directorio Windows (`/mnt/c/...`). Intentar lanzar con path `\\wsl.localhost\...` falla con "UNC paths are not supported as the current directory".
- **cmd.exe start no bloquea**: la funcion espera 3s y verifica via `tasklist.exe`. Si la app cierra sola tras el arranque (error de inicio), el warn final lo indica pero no causa exit 1. Revisar logs en `%APPDATA%\<app>\` o `%LOCALAPPDATA%\<app>\`.
- **Single-binary Wails**: no copiar DLLs. Webview2 es nativo del SO (Windows 10+ ya lo incluye). Si una version vieja de Windows no tuviera Webview2, la app falla al arrancar — solucion: instalar Webview2 Runtime en esa maquina.
- **Build previo es responsabilidad del caller**: esta funcion NO compila. Para matrix_client_pc usa `-tags goolm` por el crypto de Matrix: `wails build -platform windows/amd64 -tags goolm`.
- **WIN_DESKTOP_APPS override**: variable de entorno para cambiar el destino. Util en CI o maquinas con escritorio en otra ruta.
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# deploy_wails_exe_to_windows — Copia el .exe de una app Wails compilado en
# <app_dir>/build/bin/<app>.exe al escritorio de Windows, mata el proceso
# anterior y relanza la app. Single-binary: no copia DLLs (Webview2 nativo SO).
# Pre-authorized: taskkill.exe /F — idempotente, sin prompt.
set -euo pipefail
deploy_wails_exe_to_windows() {
local app="${1:-}"
local app_dir="${2:-}"
if [ -z "$app" ] || [ -z "$app_dir" ]; then
echo "ERROR: uso: deploy_wails_exe_to_windows <app_name> <app_dir>" >&2
echo " app_name: nombre del binario sin extension (ej. matrix_client_pc)" >&2
echo " app_dir: ruta absoluta al directorio de la app (donde vive build/bin/)" >&2
return 1
fi
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
# --- 1. Validar que el .exe existe ---
local exe_src="${app_dir}/build/bin/${app}.exe"
if [ ! -f "$exe_src" ]; then
echo "ERROR: no se encontro $exe_src" >&2
echo "Compila primero con: wails build -platform windows/amd64" >&2
return 1
fi
# --- 2. Crear directorio destino (preserva local_files/ si existe) ---
local dest="${win_desktop_apps}/${app}"
mkdir -p "$dest"
echo "[deploy_wails] dest: $dest" >&2
# --- 3. Matar proceso si esta corriendo en Windows ---
# Pre-authorized. Wails apps usan AppData+keyring para estado, kill /F es seguro.
if command -v taskkill.exe >/dev/null 2>&1; then
echo "[deploy_wails] matando ${app}.exe si corre..." >&2
taskkill.exe /IM "${app}.exe" /F 2>/dev/null || true
fi
# --- 4. Esperar a que Windows libere el file handle ---
sleep 1
# --- 5. Copiar .exe (cp -f: overwrite sin borrar el directorio) ---
echo "[deploy_wails] copiando ${app}.exe..." >&2
cp -f "$exe_src" "$dest/${app}.exe"
# --- 6. Copiar appicon.ico si existe (opcional, algunos hubs lo leen) ---
local icon_src="${app_dir}/appicon.ico"
if [ -f "$icon_src" ]; then
echo "[deploy_wails] copiando appicon.ico..." >&2
cp -f "$icon_src" "$dest/appicon.ico"
fi
# --- 7. Relanzar la app desde su dir Windows ---
# Usar cmd.exe /c start desde el dir destino (no UNC paths — falla en cmd.exe).
echo "[deploy_wails] lanzando ${app}.exe..." >&2
(
cd "$dest"
cmd.exe /c start "" "${app}.exe"
)
# --- 8. Dar tiempo a que el proceso arranque ---
sleep 3
# --- 9. Verificar que el proceso esta corriendo ---
if command -v tasklist.exe >/dev/null 2>&1; then
local tasklist_out
tasklist_out=$(tasklist.exe /FI "IMAGENAME eq ${app}.exe" /NH 2>/dev/null || true)
if echo "$tasklist_out" | grep -qi "^${app}.exe"; then
local pid
pid=$(echo "$tasklist_out" | grep -i "^${app}.exe" | awk '{print $2}' | head -n1)
echo "[deploy_wails] ${app}.exe corriendo con PID $pid" >&2
else
echo "WARN: ${app}.exe no aparece en tasklist tras el lanzamiento." >&2
echo " Puede que la app cerro con error. Revisar AppData para logs." >&2
fi
fi
# --- 10. Resumen final en stdout ---
ls -lh "$dest/${app}.exe"
echo "[deploy_wails] OK: ${app} deployado en $dest" >&2
if [ -d "$dest/local_files" ]; then
echo "[deploy_wails] local_files/ preservado: $(du -sh "$dest/local_files" | cut -f1)" >&2
fi
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
deploy_wails_exe_to_windows "$@"
fi
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Tests para deploy_wails_exe_to_windows
# Solo prueba validacion de argumentos y rutas — no ejecuta taskkill/cmd.exe reales.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/deploy_wails_exe_to_windows.sh"
PASS=0
FAIL=0
assert_eq() {
local test_name="$1" expected="$2" got="$3"
if [[ "$expected" == "$got" ]]; then
echo "PASS: $test_name"
PASS=$((PASS + 1))
else
echo "FAIL: $test_name — expected '$expected', got '$got'"
FAIL=$((FAIL + 1))
fi
}
# --- Test 1: args vacios devuelven error con mensaje de uso ---
actual_exit=0
deploy_wails_exe_to_windows >/dev/null 2>&1 || actual_exit=$?
assert_eq "args vacios devuelven error con mensaje de uso" "1" "$actual_exit"
# --- Test 2: app_dir inexistente devuelve exit 1 ---
actual_exit=0
deploy_wails_exe_to_windows "myapp" "/tmp/nonexistent_dir_$(date +%s)" >/dev/null 2>&1 || actual_exit=$?
assert_eq "app_dir inexistente devuelve exit 1" "1" "$actual_exit"
# --- Test 3: build/bin exe inexistente devuelve exit 1 ---
TMPDIR_APP=$(mktemp -d)
# Crear estructura de dir de app pero SIN el exe
mkdir -p "$TMPDIR_APP/build/bin"
actual_exit=0
deploy_wails_exe_to_windows "myapp" "$TMPDIR_APP" >/dev/null 2>&1 || actual_exit=$?
rm -rf "$TMPDIR_APP"
assert_eq "build/bin exe inexistente devuelve exit 1" "1" "$actual_exit"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
+3 -3
View File
@@ -30,15 +30,15 @@ file_path: "bash/functions/infra/discover_git_repos.sh"
source bash/functions/infra/discover_git_repos.sh
# Listar todos los repos bajo fn_registry
discover_git_repos /home/lucas/fn_registry
discover_git_repos $HOME/fn_registry
# Contar repos
discover_git_repos /home/lucas/fn_registry | wc -l
discover_git_repos $HOME/fn_registry | wc -l
# Iterar
while IFS= read -r repo; do
echo "Repo: $repo"
done < <(discover_git_repos /home/lucas/fn_registry)
done < <(discover_git_repos $HOME/fn_registry)
```
## Notas
+1 -1
View File
@@ -33,7 +33,7 @@ file_path: "bash/functions/infra/docker_cp_file.sh"
```bash
source functions/infra/docker_cp_file.sh
result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
result=$(docker_cp_file $HOME/fn_registry/registry.db metabase /registry.db)
echo "$result"
# {"local_size":524288,"remote_size":524288}
@@ -32,7 +32,7 @@ file_path: "bash/functions/infra/git_auto_commit_dirty.sh"
source bash/functions/infra/git_auto_commit_dirty.sh
# Commitear con mensaje automatico
subject=$(git_auto_commit_dirty /home/lucas/fn_registry)
subject=$(git_auto_commit_dirty $HOME/fn_registry)
echo "Commit: $subject"
# Commitear con mensaje fijo
@@ -17,7 +17,7 @@ error_type: "error_go_core"
imports: []
example: |
# Manual check
bash bash/functions/infra/git_hook_audit_app_drift.sh /home/lucas/fn_registry/apps/kanban
bash bash/functions/infra/git_hook_audit_app_drift.sh $HOME/fn_registry/apps/kanban
# Used by pre_commit_hook_install_bash_infra (v2 hook chain)
file_path: bash/functions/infra/git_hook_audit_app_drift.sh
+2 -2
View File
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_pull_with_stash.sh"
source bash/functions/infra/git_pull_with_stash.sh
# Pullear repo con auto-stash
status=$(git_pull_with_stash /home/lucas/fn_registry)
status=$(git_pull_with_stash $HOME/fn_registry)
echo "$status"
# [pulled] fn_registry
# o:
@@ -46,7 +46,7 @@ while IFS= read -r repo; do
if [[ "$result" == "[diverged]"* || "$result" == "[stash-conflict]"* ]]; then
diverged+=("$result")
fi
done < <(discover_git_repos /home/lucas/fn_registry)
done < <(discover_git_repos $HOME/fn_registry)
if [[ ${#diverged[@]} -gt 0 ]]; then
echo "ATENCION: repos que requieren intervencion manual:"
+2 -2
View File
@@ -30,7 +30,7 @@ file_path: "bash/functions/infra/git_push_if_ahead.sh"
source bash/functions/infra/git_push_if_ahead.sh
# Pushear si hay commits locales
status=$(git_push_if_ahead /home/lucas/fn_registry)
status=$(git_push_if_ahead $HOME/fn_registry)
echo "$status"
# [push] fn_registry (master, 3 commits ahead)
# o:
@@ -39,7 +39,7 @@ echo "$status"
# Iterar sobre multiples repos
while IFS= read -r repo; do
git_push_if_ahead "$repo"
done < <(discover_git_repos /home/lucas/fn_registry)
done < <(discover_git_repos $HOME/fn_registry)
```
## Estados de salida
+76
View File
@@ -0,0 +1,76 @@
---
name: jupyter_mcp_serve
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
error_type: "error_go_core"
signature: "jupyter_mcp_serve.sh [--dry-run]"
description: "Arranca (o reusa) un Jupyter Lab colaborativo en un puerto propio y lanza el Jupyter MCP server enganchado por stdio. Entrypoint robusto para la entrada 'jupyter' de .mcp.json: garantiza que el MCP SIEMPRE tiene servidor al que conectarse, sin depender de que haya un jupyter en 8888."
tags: [notebook, jupyter, mcp, infra, launcher-glue]
uses_functions: []
uses_types: []
params:
- name: "--dry-run"
desc: "Opcional. Arranca/verifica jupyter pero NO hace exec del MCP; loguea el comando elegido. Para tests."
output: "Proceso jupyter-mcp-server enganchado por stdio a un Jupyter Lab colaborativo local (127.0.0.1, puerto JUPYTER_MCP_PORT, default 8899). Logs en ~/.fn_jupyter_mcp/. stdout reservado al protocolo MCP."
---
## Que hace
El MCP de Jupyter (datalayer `jupyter-mcp-server`) **no arranca jupyter**, solo se
conecta a uno existente. Si la URL configurada no tiene jupyter detras, el MCP
nunca conecta. En esta maquina `localhost:8888` es el **proxy HTTP del contenedor
VPN gluetun**, no un jupyter — por eso el MCP fallaba siempre.
Este wrapper resuelve la cadena entera:
1. Localiza el venv (`python/.venv`) y los binarios `jupyter` + `jupyter-mcp-server`.
2. Si ya hay un jupyter gestionado vivo en `127.0.0.1:$PORT` (`/api/status` = 200) lo reusa.
3. Si no, arranca `jupyter lab` colaborativo detached (RTC via `jupyter-collaboration`),
en `JUPYTER_MCP_ROOT` (default = raiz del repo, asi cualquier notebook del arbol es lanzable).
4. Detecta el dialecto de CLI del MCP (`--document-url` nuevo / `--jupyter-url` viejo / env vars)
y hace `exec` del MCP por `--transport stdio`.
Self-adapting: funciona aunque cambie la version de `jupyter-mcp-server`.
## Ejemplo
```bash
# Como lo usa Claude Code (entrada en .mcp.json):
# "jupyter": { "command": "bash", "args": ["bash/functions/infra/jupyter_mcp_serve.sh"] }
# Test manual (arranca jupyter en 8899, no lanza el MCP):
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
curl -s http://127.0.0.1:8899/api/status # {"started":..., "version":...}
# Cambiar puerto / raiz de notebooks:
JUPYTER_MCP_PORT=8900 JUPYTER_MCP_ROOT=/home/enmanuel/fn_registry/analysis \
bash bash/functions/infra/jupyter_mcp_serve.sh --dry-run
```
## Cuando usarla
Cuando quieras que el MCP de Jupyter de Claude Code **siempre** tenga servidor:
es el `command` de la entrada `jupyter` en `.mcp.json`. No la invoques a mano salvo
para depurar (`--dry-run`) o para levantar el jupyter colaborativo sin el MCP.
## Gotchas
- **stdout reservado**: el MCP habla por stdout (protocolo stdio). El wrapper jamas
escribe a stdout — todo log va a stderr y `~/.fn_jupyter_mcp/wrapper.log`. No metas
`echo` a stdout aqui o rompes el handshake del MCP.
- **Puerto 8888 ocupado por gluetun** en esta maquina. Por eso el default es **8899**.
Si 8899 tambien se ocupa, exporta `JUPYTER_MCP_PORT`.
- **Token vacio**: solo escucha en `127.0.0.1` con `disable_check_xsrf` + `allow_origin '*'`.
Aceptable en local; NO exponer el puerto a la red.
- **venv requerido**: necesita `python/.venv` con `jupyterlab`, `jupyter-collaboration`
y `jupyter-mcp-server`. Reconstruir: `cd python && uv sync --extra jupyter`.
- El jupyter arrancado queda **detached** (nohup): persiste entre invocaciones del MCP.
Para pararlo: `python/.venv/bin/jupyter server stop 8899` o `pkill -f 'jupyter-lab.*8899'`.
## Capability growth log
v1.0.0 (2026-06-01) — version inicial. Wrapper auto-start: reusa/levanta jupyter
colaborativo en puerto propio (8899) y autodetecta el dialecto de CLI del MCP.
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# jupyter_mcp_serve — arranca (o reusa) un Jupyter Lab colaborativo y lanza el
# Jupyter MCP server enganchado a el por stdio. Pensado para ser el `command` de
# la entrada "jupyter" en .mcp.json: garantiza que el MCP SIEMPRE tiene servidor.
#
# Por que existe: el MCP datalayer NO arranca jupyter, solo se conecta. Si la URL
# apunta a un puerto sin jupyter (en esta maquina 8888 = proxy VPN gluetun), el
# MCP nunca conecta. Este wrapper levanta su propio jupyter en un puerto propio.
#
# Env overrides:
# JUPYTER_MCP_ROOT raiz de notebooks (default: raiz del repo)
# JUPYTER_MCP_PORT puerto del jupyter gestionado (default: 8899)
# JUPYTER_MCP_VENV venv (default: <repo>/python/.venv)
# JUPYTER_MCP_TOKEN token (default: "" — solo escucha en 127.0.0.1)
#
# stdout esta RESERVADO al protocolo stdio del MCP. Todo log va a stderr + LOGFILE.
# Nunca hacer echo a stdout aqui.
#
# Uso directo / test:
# bash jupyter_mcp_serve.sh --dry-run # arranca jupyter, NO exec del MCP, loguea args
set -euo pipefail
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# raiz del repo = tres niveles arriba de bash/functions/infra/
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
VENV="${JUPYTER_MCP_VENV:-$REPO_ROOT/python/.venv}"
ROOT_DIR="${JUPYTER_MCP_ROOT:-$REPO_ROOT}"
PORT="${JUPYTER_MCP_PORT:-8899}"
HOST=127.0.0.1
TOKEN="${JUPYTER_MCP_TOKEN:-}"
LOGDIR="${HOME}/.fn_jupyter_mcp"
mkdir -p "$LOGDIR"
LOGFILE="$LOGDIR/wrapper.log"
JLOG="$LOGDIR/jupyterlab.log"
log(){ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; printf '%s\n' "$*" >&2; }
JUPYTER="$VENV/bin/jupyter"
MCP="$VENV/bin/jupyter-mcp-server"
if [ ! -x "$JUPYTER" ]; then
log "FATAL: $JUPYTER no existe. Instala: cd $REPO_ROOT/python && uv pip install --python .venv/bin/python3 jupyterlab jupyter-collaboration jupyter-mcp-server"
exit 1
fi
if [ ! -x "$MCP" ]; then
log "FATAL: $MCP no existe. Instala jupyter-mcp-server en el venv."
exit 1
fi
server_up(){
local code
code="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/status?token=$TOKEN" 2>/dev/null || true)"
[ "$code" = "200" ]
}
if server_up; then
log "reuso jupyter existente en $HOST:$PORT"
else
log "arranco jupyter colaborativo en $HOST:$PORT (root=$ROOT_DIR)"
nohup "$JUPYTER" lab \
--no-browser \
--ServerApp.ip="$HOST" \
--ServerApp.port="$PORT" \
--ServerApp.root_dir="$ROOT_DIR" \
--IdentityProvider.token="$TOKEN" \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
>>"$JLOG" 2>&1 &
disown 2>/dev/null || true
# esperar hasta ~30s a que levante
for _ in $(seq 1 60); do
server_up && break
sleep 0.5
done
if ! server_up; then
log "FATAL: jupyter no levanto en 30s. Ver $JLOG"
exit 1
fi
log "jupyter arriba"
fi
BASE="http://$HOST:$PORT"
# Detectar el dialecto de CLI del MCP (cambia entre versiones de jupyter-mcp-server)
HELP="$("$MCP" --help 2>&1 || true)"
ARGS=(--transport stdio)
if printf '%s' "$HELP" | grep -q -- '--document-url'; then
ARGS+=(--document-url "$BASE" --runtime-url "$BASE")
printf '%s' "$HELP" | grep -q -- '--document-token' && ARGS+=(--document-token "$TOKEN" --runtime-token "$TOKEN")
elif printf '%s' "$HELP" | grep -q -- '--jupyter-url'; then
ARGS+=(--jupyter-url "$BASE" --jupyter-token "$TOKEN")
else
# fallback: variables de entorno que las distintas versiones reconocen
export DOCUMENT_URL="$BASE" RUNTIME_URL="$BASE" DOCUMENT_TOKEN="$TOKEN" RUNTIME_TOKEN="$TOKEN"
export JUPYTER_URL="$BASE" JUPYTER_TOKEN="$TOKEN"
fi
log "MCP cmd: $MCP ${ARGS[*]}"
if [ "$DRY" = "1" ]; then
log "--dry-run: no ejecuto el MCP. Jupyter sigue corriendo en $BASE"
exit 0
fi
exec "$MCP" "${ARGS[@]}"
@@ -61,7 +61,7 @@ Mitad complementaria de `deploy_cpp_exe_to_windows_bash_infra`. El flujo complet
build_cpp_windows "registry_dashboard"
# 2. Copiar al escritorio (mata proceso si corre, copia DLLs+assets)
deploy_cpp_exe_to_windows "registry_dashboard" "/home/lucas/fn_registry/apps/registry_dashboard"
deploy_cpp_exe_to_windows "registry_dashboard" "$HOME/fn_registry/apps/registry_dashboard"
# 3. Lanzar
launch_cpp_app_windows "registry_dashboard"
@@ -0,0 +1,89 @@
---
name: mas_client_register
kind: function
lang: bash
domain: infra
version: "0.1.0"
purity: impure
signature: "mas_client_register(ssh_host: string, container: string, config_file: string, dry_run: bool) -> json"
description: "Registra y sincroniza clientes OAuth en Matrix Authentication Service (MAS) ejecutando mas-cli config sync dentro del container Docker remoto via SSH. Verifica sintaxis YAML, soporte dry-run para ver diff antes de aplicar, y emite JSON estructurado con resultado. Idempotente: re-ejecucion con misma config no genera cambios."
tags: [matrix, mas, oauth, oidc, migration, mas-migration, infra, docker, ssh, matrix-mas]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: ssh_host
desc: "alias SSH del VPS donde corre MAS (ej. organic-machine.com). Debe estar en ~/.ssh/config con key auth."
- name: container
desc: "nombre del container Docker con MAS (ej. element_matrix_chat-mas-1). El config dentro del container se espera en /data/config.yaml."
- name: config_file
desc: "ruta absoluta en el VPS al archivo mas/config.yaml (ej. /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml). MAS lo monta como /data/config.yaml."
- name: dry_run
desc: "flag opcional --dry-run: ejecuta mas-cli config dump y devuelve el estado sin aplicar cambios. Util para verificar antes de activar MSC3861."
output: "JSON con: status ('ok'|'dry-run'|'error'), applied (bool), clients_total (int), clients_diff (array de lineas del output de mas-cli), stderr (string con logs de error si aplica)."
tested: true
tests:
- "help flag emite JSON parseable"
- "args faltantes retornan JSON de error sin ssh"
- "jq disponible en host local"
test_file_path: "bash/functions/infra/mas_client_register_test.sh"
file_path: "bash/functions/infra/mas_client_register.sh"
---
## Ejemplo
```bash
# Dry-run: verificar que clients se aplicarian correctamente
source bash/functions/infra/mas_client_register.sh
mas_client_register \
--ssh-host organic-machine.com \
--container element_matrix_chat-mas-1 \
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml \
--dry-run
# Aplicar sync real (con --prune para eliminar clients viejos)
mas_client_register \
--ssh-host organic-machine.com \
--container element_matrix_chat-mas-1 \
--config-file /home/ubuntu/CodeProyects/element_matrix_chat/mas/config.yaml
```
Salida esperada (sync OK):
```json
{
"status": "ok",
"applied": true,
"clients_total": 6,
"clients_diff": ["synced client element-web", "synced client synapse-admin", "..."],
"stderr": ""
}
```
Salida dry-run:
```json
{
"status": "dry-run",
"applied": false,
"clients_total": 42,
"clients_diff": ["clients:", " - client_id: element-web", " ..."],
"stderr": ""
}
```
## Cuando usarla
Usar despues de editar `mas/config.yaml` localmente y antes de hacer restart a Synapse con `msc3861` habilitado en `homeserver.yaml`. Ejecutar primero con `--dry-run` para verificar que los 6 clients OAuth (Element Web, Synapse-Admin, matrix_client_pc, matrix_client_android, matrix_admin_panel, Synapse-internal) estan correctamente definidos, luego sin `--dry-run` para aplicar el sync.
## Gotchas
- **`--prune` elimina clients no declarados en config**: el sync real usa `--prune`, lo que borra cualquier client OAuth que exista en MAS pero no este en el `config.yaml`. Verificar con `--dry-run` antes de aplicar en produccion.
- **Requiere `jq` en el host local**: el JSON output se construye con `jq`. Si no esta instalado, la funcion falla con error claro antes de conectar al VPS.
- **`mas-cli` debe estar en el container**: la funcion asume que `mas-cli` esta en el PATH dentro del container MAS. Si el container usa una imagen diferente, verificar con `docker exec <container> mas-cli --version`.
- **Config dentro del container siempre en `/data/config.yaml`**: el `--config-file` apunta a la ruta en el VPS (para que el operador sepa que archivo editar), pero el comando dentro del container usa `/data/config.yaml` (el mount point estandar de MAS). Si el compose monta el archivo en otro path, ajustar la constante `container_config` en el script.
- **SSH key debe estar en agent o `~/.ssh/config`**: la funcion usa `ssh <alias>` directamente. Si la key requiere passphrase, ejecutar `ssh-add` antes.
- **Si `config.yaml` es invalido, sync aborta sin tocar estado**: el paso 1 (`mas-cli config check`) detecta errores de sintaxis YAML antes de intentar sync. El estado de MAS no se modifica si la config tiene errores.
- **Idempotente**: re-ejecutar con la misma config no genera cambios en MAS (mas-cli detecta que el estado ya coincide).
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# mas_client_register — Registra/sincroniza clientes OAuth en Matrix Authentication Service (MAS)
# via mas-cli config sync ejecutado en container Docker remoto a traves de SSH.
set -euo pipefail
mas_client_register() {
local ssh_host=""
local container=""
local config_file=""
local dry_run=false
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--ssh-host)
ssh_host="$2"
shift 2
;;
--container)
container="$2"
shift 2
;;
--config-file)
config_file="$2"
shift 2
;;
--dry-run)
dry_run=true
shift
;;
--help|-h)
cat >&2 <<'USAGE'
mas_client_register - Sincroniza clientes OAuth en MAS via mas-cli config sync
Usage:
mas_client_register --ssh-host <host> --container <name> --config-file <path> [--dry-run]
Options:
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
--container Nombre del container MAS (ej. element_matrix_chat-mas-1)
--config-file Ruta en el VPS al mas/config.yaml (ej. /home/ubuntu/project/mas/config.yaml)
--dry-run Solo valida config y muestra diff, sin aplicar cambios
Output: JSON en stdout con status, applied, clients_total, clients_diff, stderr
USAGE
# emit minimal valid JSON so callers that parse stdout don't break
echo '{"status":"help","applied":false,"clients_total":0,"clients_diff":[],"stderr":""}'
return 0
;;
*)
echo "mas_client_register: argumento desconocido: $1" >&2
return 1
;;
esac
done
# Validar argumentos obligatorios
local errors=()
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
[[ -z "$container" ]] && errors+=("--container es obligatorio")
[[ -z "$config_file" ]] && errors+=("--config-file es obligatorio")
if [[ ${#errors[@]} -gt 0 ]]; then
for err in "${errors[@]}"; do
echo "ERROR: $err" >&2
done
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"missing required arguments"}'
return 1
fi
# Verificar dependencias locales
if ! command -v jq &>/dev/null; then
echo "ERROR: jq no encontrado en el host local. Instalar: apt install jq / brew install jq" >&2
echo '{"status":"error","applied":false,"clients_total":0,"clients_diff":[],"stderr":"jq not found on local host"}'
return 1
fi
echo "mas_client_register: ssh-host=$ssh_host container=$container dry-run=$dry_run" >&2
# La ruta de config dentro del container siempre es /data/config.yaml (mount convention de MAS)
local container_config="/data/config.yaml"
# ---- PASO 1: Verificar sintaxis YAML con mas-cli config check ----
echo "mas_client_register: verificando sintaxis de config con mas-cli config check..." >&2
local check_stdout check_stderr check_exit
check_stdout=$(ssh "$ssh_host" \
"docker exec ${container} mas-cli config check --config ${container_config}" 2>/tmp/mas_check_stderr_$$ || true)
check_exit=$?
check_stderr=$(cat /tmp/mas_check_stderr_$$ 2>/dev/null || true)
rm -f /tmp/mas_check_stderr_$$
if [[ $check_exit -ne 0 ]]; then
echo "mas_client_register: config check falló (exit=$check_exit)" >&2
echo "$check_stderr" >&2
local escaped_stderr
escaped_stderr=$(printf '%s' "${check_stderr}" | jq -Rs '.')
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
return 1
fi
echo "mas_client_register: config check OK" >&2
# ---- PASO 2: dry-run o sync ----
if [[ "$dry_run" == "true" ]]; then
# Ejecutar mas-cli config dump para mostrar el estado actual y lo que se aplicaria
echo "mas_client_register: modo dry-run — ejecutando mas-cli config dump..." >&2
local dump_stdout dump_stderr dump_exit
dump_stdout=$(ssh "$ssh_host" \
"docker exec ${container} mas-cli config dump --config ${container_config}" 2>/tmp/mas_dump_stderr_$$ || true)
dump_exit=$?
dump_stderr=$(cat /tmp/mas_dump_stderr_$$ 2>/dev/null || true)
rm -f /tmp/mas_dump_stderr_$$
if [[ $dump_exit -ne 0 ]]; then
echo "mas_client_register: config dump falló (exit=$dump_exit)" >&2
echo "$dump_stderr" >&2
local escaped_stderr
escaped_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
return 1
fi
# Extraer listado de clients del dump (buscar lineas con client_id o type: client)
local clients_diff_raw
clients_diff_raw=$(printf '%s\n' "$dump_stdout" | grep -E "client_id:|client_name:" | \
sed 's/^[[:space:]]*//' | head -50 || true)
local diff_json
diff_json=$(printf '%s\n' "$dump_stdout" | jq -Rs 'split("\n") | map(select(length > 0)) | map(ltrimstr(" "))' 2>/dev/null \
|| echo '["(jq parse error — ver stderr)"]')
local escaped_dump_stderr
escaped_dump_stderr=$(printf '%s' "${dump_stderr}" | jq -Rs '.')
echo "mas_client_register: dry-run completado. dump lines=$(echo "$dump_stdout" | wc -l)" >&2
jq -n \
--argjson diff "$diff_json" \
--argjson stderr_str "$escaped_dump_stderr" \
'{
status: "dry-run",
applied: false,
clients_total: ($diff | length),
clients_diff: $diff,
stderr: $stderr_str
}'
return 0
fi
# ---- PASO 3: sync real ----
echo "mas_client_register: ejecutando mas-cli config sync --prune..." >&2
local sync_stdout sync_stderr sync_exit
sync_stdout=$(ssh "$ssh_host" \
"docker exec ${container} mas-cli config sync --config ${container_config} --prune" \
2>/tmp/mas_sync_stderr_$$ || true)
sync_exit=$?
sync_stderr=$(cat /tmp/mas_sync_stderr_$$ 2>/dev/null || true)
rm -f /tmp/mas_sync_stderr_$$
echo "mas_client_register: sync exit=$sync_exit" >&2
if [[ -n "$sync_stderr" ]]; then
echo "mas_client_register stderr: $sync_stderr" >&2
fi
if [[ $sync_exit -ne 0 ]]; then
local escaped_stderr
escaped_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
echo "{\"status\":\"error\",\"applied\":false,\"clients_total\":0,\"clients_diff\":[],\"stderr\":${escaped_stderr}}"
return 1
fi
# Parsear output del sync para extraer lineas con cambios aplicados
local diff_lines
diff_lines=$(printf '%s\n' "$sync_stdout" | grep -E "^\s*(created|updated|deleted|unchanged|synced)" || true)
local diff_json
diff_json=$(printf '%s\n' "$sync_stdout" | jq -Rs 'split("\n") | map(select(length > 0))' 2>/dev/null \
|| echo '[]')
local clients_count
clients_count=$(printf '%s\n' "$sync_stdout" | grep -cE "client" 2>/dev/null || echo 0)
local escaped_sync_stderr
escaped_sync_stderr=$(printf '%s' "${sync_stderr}" | jq -Rs '.')
echo "mas_client_register: sync completado con exito" >&2
jq -n \
--argjson diff "$diff_json" \
--argjson total "$clients_count" \
--argjson stderr_str "$escaped_sync_stderr" \
'{
status: "ok",
applied: true,
clients_total: $total,
clients_diff: $diff,
stderr: $stderr_str
}'
}
# Ejecutar si se llama directamente (no sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mas_client_register "$@"
fi
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Tests para mas_client_register
# No requiere SSH real — prueba paths locales (arg validation, --help, JSON output)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PASS=0
FAIL=0
assert_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
((PASS++))
else
echo "FAIL: $test_name — expected to contain '$needle', got: $haystack"
((FAIL++))
fi
}
assert_json_parseable() {
local test_name="$1" json="$2"
if command -v jq &>/dev/null; then
if echo "$json" | jq . >/dev/null 2>&1; then
echo "PASS: $test_name"
((PASS++))
else
echo "FAIL: $test_name — output no es JSON valido: $json"
((FAIL++))
fi
else
if [[ "$json" == \{* ]]; then
echo "PASS: $test_name (jq no disponible, verificacion basica OK)"
((PASS++))
else
echo "FAIL: $test_name — output no parece JSON: $json"
((FAIL++))
fi
fi
}
# Test: help flag emite JSON parseable
# Cada invocacion en subshell aislada para no contaminar el runner con set -e del script fuente
bash "$SCRIPT_DIR/mas_client_register.sh" --help >/tmp/mas_test_help_$$ 2>/dev/null || true
output_help=$(cat /tmp/mas_test_help_$$ 2>/dev/null || true)
rm -f /tmp/mas_test_help_$$
assert_json_parseable "help flag emite JSON parseable" "$output_help"
# Test: args faltantes retornan JSON de error sin ssh
bash "$SCRIPT_DIR/mas_client_register.sh" >/tmp/mas_test_noargs_$$ 2>/dev/null || true
output_noargs=$(cat /tmp/mas_test_noargs_$$ 2>/dev/null || true)
rm -f /tmp/mas_test_noargs_$$
assert_json_parseable "args faltantes retornan JSON de error sin ssh" "$output_noargs"
assert_contains "args faltantes contienen status error" '"status":"error"' "$output_noargs"
# Test: jq disponible en host local
if command -v jq &>/dev/null; then
echo "PASS: jq disponible en host local"
((PASS++))
else
echo "FAIL: jq disponible en host local — instalar: apt install jq"
((FAIL++))
fi
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
@@ -0,0 +1,83 @@
---
name: mas_syn2mas_migration
kind: function
lang: bash
domain: infra
version: "0.1.0"
purity: impure
signature: "mas_syn2mas_migration --ssh-host <host> --mas-container <name> --synapse-config-path <path-on-host> --log-dir <local-path> [--max-conflicts N] [--apply]"
description: "Migra usuarios Synapse a Matrix Authentication Service (MAS) via mas-cli syn2mas. Fuerza dry-run primero, archiva el log, aborta si los conflicts superan el threshold, y solo ejecuta la migracion real con --apply."
tags: [matrix, mas, syn2mas, migration, mas-migration, infra, users, docker, ssh, matrix-mas]
params:
- name: ssh-host
desc: "Alias SSH del VPS donde corren los containers (ej. organic-machine.com)"
- name: mas-container
desc: "Nombre del container Docker de MAS (ej. element_matrix_chat-mas-1)"
- name: synapse-config-path
desc: "Ruta en el VPS al homeserver.yaml de Synapse (ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml). El container debe tener el archivo accesible en /data/homeserver.yaml via volume mount."
- name: log-dir
desc: "Directorio local donde archivar logs dry-run y apply. Se crea con chmod 0700 y los logs con 0600 (contienen userIDs)."
- name: max-conflicts
desc: "Tope de conflictos detectados en dry-run. Si conflicts > max-conflicts, status=aborted exit 2. Default 0 (abortar ante cualquier conflict)."
- name: apply
desc: "Flag booleano. Sin --apply: solo dry-run (status=ok, sin cambios). Con --apply: ejecuta la migracion real tras pasar el threshold."
output: "JSON en stdout: {\"status\":\"ok|aborted|error\",\"dry_run_log\":\"path\",\"apply_log\":\"path|null\",\"conflicts\":N,\"users_migrated\":N,\"duration_s\":N}. Exit 0=ok, 1=error, 2=aborted por conflicts."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "aborta con error cuando faltan args obligatorios"
- "help no devuelve error"
- "argumento desconocido retorna exit 1"
- "max-conflicts invalido retorna exit 1"
test_file_path: "bash/functions/infra/mas_syn2mas_migration_test.sh"
file_path: "bash/functions/infra/mas_syn2mas_migration.sh"
---
## Ejemplo
```bash
# Paso 1: dry-run OBLIGATORIO (sin --apply — no modifica nada)
mas_syn2mas_migration \
--ssh-host organic-machine.com \
--mas-container element_matrix_chat-mas-1 \
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
--log-dir ~/matrix_migration_logs \
--max-conflicts 0
# Salida esperada (si hay 0 conflicts):
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}
# Revisar el log antes de continuar:
# cat ~/matrix_migration_logs/syn2mas_dryrun_*.log
# Paso 2: tras revisar el log dry-run, aplicar la migracion real
mas_syn2mas_migration \
--ssh-host organic-machine.com \
--mas-container element_matrix_chat-mas-1 \
--synapse-config-path /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml \
--log-dir ~/matrix_migration_logs \
--max-conflicts 0 \
--apply
# Salida esperada tras migracion exitosa:
# {"status":"ok","dry_run_log":"/home/lucas/matrix_migration_logs/syn2mas_dryrun_1234567890.log","apply_log":"/home/lucas/matrix_migration_logs/syn2mas_apply_1234567890.log","conflicts":0,"users_migrated":42,"duration_s":15}
```
## Cuando usarla
Usar en el paso 4 de la migracion del issue 0162 (Synapse a MAS auth), tras activar MSC3861 en `homeserver.yaml` y verificar que MAS esta corriendo con `syn2mas: true` en su config. NUNCA ejecutar antes de activar MSC3861 — sin ese flag activo, `syn2mas` no puede mapear usuarios a las tablas MAS y la migracion resultara en estado inconsistente.
## Gotchas
- **Dry-run NO modifica nada** — siempre ejecutar primero sin `--apply` y revisar el log manualmente antes de aplicar.
- Si el dry-run detecta usuarios con **guest accounts**, **application services** (bots), o **passwords externos** (LDAP/OIDC), revisar manualmente el log antes de aplicar — estos casos pueden requerir steps adicionales documentados en el issue 0162.
- **Backup postgres pre-migracion NO esta cubierto** por esta funcion. El operador es responsable de hacer `pg_dump` de la DB de Synapse antes de ejecutar con `--apply`. Ver issue 0162 paso 1.
- Si la migracion real falla **a mitad**, MAS puede quedar en estado inconsistente con usuarios parcialmente migrados. El rollback consiste en restaurar el backup postgres de Synapse + revertir `homeserver.yaml` a la configuracion pre-MSC3861.
- Los logs archivados en `--log-dir` **incluyen userIDs** (datos personales). Se crean con permisos `0600` (solo propietario puede leer). Mantener el directorio con `chmod 0700`. No subir los logs a repos publicos.
- El comando `mas-cli syn2mas` en el container asume que `homeserver.yaml` esta montado en `/data/homeserver.yaml`. Si el volume mount del container usa otra ruta, el comando fallara con "file not found". Verificar con `docker inspect <container> | jq '.[].Mounts'`.
- La postcondicion compara el count de usuarios MAS con una segunda ejecucion de dry-run para obtener el count esperado. Si el conteo no esta disponible (salida inesperada de mas-cli), la funcion emite `status=ok` con `users_migrated` del count real de MAS — no aborta por este motivo para evitar falsos negativos.
@@ -0,0 +1,325 @@
#!/usr/bin/env bash
# mas_syn2mas_migration — Migra usuarios Synapse a MAS via mas-cli syn2mas.
# Fuerza dry-run primero, archiva el log, aborta si conflicts > threshold,
# y solo ejecuta la migracion real cuando se pasa --apply.
#
# Usage:
# mas_syn2mas_migration --ssh-host <host> --mas-container <name> \
# --synapse-config-path <path-on-host> --log-dir <local-path> \
# [--max-conflicts N] [--apply]
#
# Output: JSON en stdout con status, dry_run_log, apply_log, conflicts, users_migrated, duration_s
set -euo pipefail
mas_syn2mas_migration() {
local ssh_host=""
local mas_container=""
local synapse_config_path=""
local log_dir=""
local max_conflicts=0
local do_apply=false
# ---- Parse args ----
while [[ $# -gt 0 ]]; do
case "$1" in
--ssh-host)
ssh_host="$2"
shift 2
;;
--mas-container)
mas_container="$2"
shift 2
;;
--synapse-config-path)
synapse_config_path="$2"
shift 2
;;
--log-dir)
log_dir="$2"
shift 2
;;
--max-conflicts)
max_conflicts="$2"
shift 2
;;
--apply)
do_apply=true
shift
;;
--help|-h)
cat >&2 <<'USAGE'
mas_syn2mas_migration - Migra usuarios Synapse a Matrix Authentication Service (MAS)
Usage:
mas_syn2mas_migration \
--ssh-host <host> \
--mas-container <name> \
--synapse-config-path <path-on-host> \
--log-dir <local-path> \
[--max-conflicts N] \
[--apply]
Opciones:
--ssh-host Alias SSH del VPS (ej. organic-machine.com)
--mas-container Nombre del container MAS (ej. element_matrix_chat-mas-1)
--synapse-config-path Ruta en el VPS al homeserver.yaml
(ej. /home/ubuntu/CodeProyects/element_matrix_chat/synapse_data/homeserver.yaml)
--log-dir Directorio local donde archivar logs dry-run y apply
--max-conflicts N Tope de conflictos en dry-run antes de abortar (default 0)
--apply Ejecutar migracion real. Sin esta flag: solo dry-run.
Comportamiento:
1. Siempre ejecuta dry-run primero y archiva el log.
2. Si conflicts > max-conflicts -> status=aborted, exit 2.
3. Sin --apply -> status=ok (dry-run completado), exit 0.
4. Con --apply -> ejecuta migracion real, archiva log, verifica postcondicion.
Output JSON: {"status":"ok|aborted|error","dry_run_log":"path","apply_log":"path|null","conflicts":N,"users_migrated":N,"duration_s":N}
USAGE
echo '{"status":"help","dry_run_log":"","apply_log":null,"conflicts":0,"users_migrated":0,"duration_s":0}'
return 0
;;
*)
echo "mas_syn2mas_migration: argumento desconocido: $1" >&2
return 1
;;
esac
done
# ---- Validar argumentos obligatorios ----
local errors=()
[[ -z "$ssh_host" ]] && errors+=("--ssh-host es obligatorio")
[[ -z "$mas_container" ]] && errors+=("--mas-container es obligatorio")
[[ -z "$synapse_config_path" ]] && errors+=("--synapse-config-path es obligatorio")
[[ -z "$log_dir" ]] && errors+=("--log-dir es obligatorio")
if [[ ${#errors[@]} -gt 0 ]]; then
for err in "${errors[@]}"; do
echo "ERROR: $err" >&2
done
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
return 1
fi
# Validar que max_conflicts es un entero no negativo
if ! [[ "$max_conflicts" =~ ^[0-9]+$ ]]; then
echo "ERROR: --max-conflicts debe ser un entero >= 0, recibido: $max_conflicts" >&2
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
return 1
fi
# ---- Dependencias locales ----
if ! command -v jq &>/dev/null; then
echo "ERROR: jq no encontrado. Instalar: apt install jq / brew install jq" >&2
echo '{"status":"error","dry_run_log":"","apply_log":null,"conflicts":-1,"users_migrated":0,"duration_s":0}'
return 1
fi
# ---- Crear log-dir con permisos restringidos ----
mkdir -p "$log_dir"
chmod 0700 "$log_dir"
local ts
ts=$(date +%s)
local dry_run_log="${log_dir}/syn2mas_dryrun_${ts}.log"
local apply_log_path="null"
local apply_log_file="${log_dir}/syn2mas_apply_${ts}.log"
# La ruta del homeserver.yaml dentro del container MAS se pasa como --synapse-config
# MAS monta el directorio del synapse bajo /data/ por convencion, pero la ruta real
# puede variar — usamos la ruta tal como existe en el host (montada via volume).
# El comando real esperado: docker exec <container> mas-cli syn2mas --synapse-config <path>
# donde <path> es la ruta tal como el container la ve (via volume mount).
# Asumimos que el VPS tiene el config accesible en la misma ruta dentro del container.
local container_config="/data/homeserver.yaml"
echo "mas_syn2mas_migration: ssh-host=${ssh_host} container=${mas_container} max-conflicts=${max_conflicts} apply=${do_apply}" >&2
# =========================================================================
# PASO 1: DRY-RUN obligatorio
# =========================================================================
echo "mas_syn2mas_migration: ejecutando dry-run..." >&2
local dry_exit=0
# Capturar stdout+stderr del dry-run en el log y tambien en variable para parsing
local dry_output
dry_output=$(ssh "$ssh_host" \
"docker exec '${mas_container}' mas-cli syn2mas \
--synapse-config '${container_config}' \
--dry-run" \
2>&1) || dry_exit=$?
# Archivar log con timestamp + header informativo
{
echo "# mas_syn2mas_migration dry-run"
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
echo "# synapse-config-path=${synapse_config_path}"
echo "# exit=${dry_exit}"
echo "# ---"
printf '%s\n' "$dry_output"
} > "$dry_run_log"
chmod 0600 "$dry_run_log"
echo "mas_syn2mas_migration: dry-run exit=${dry_exit}, log=${dry_run_log}" >&2
if [[ $dry_exit -ne 0 ]]; then
# Si el comando SSH falla completamente (no es fallo de syn2mas sino de conectividad)
echo "mas_syn2mas_migration: ERROR — dry-run falló con exit ${dry_exit}" >&2
local escaped_out
escaped_out=$(printf '%s' "${dry_output}" | jq -Rs '.')
local dry_run_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
return 1
fi
# =========================================================================
# PASO 2: Parsear conflicts del dry-run
# =========================================================================
# Regex sobre lineas tipo:
# "Conflict:", "Skipping:", "Error processing user", "conflict"
# También contamos líneas que indiquen usuarios problemáticos.
local conflicts=0
local conflict_lines
conflict_lines=$(printf '%s\n' "$dry_output" | \
grep -ciE '(conflict|skipping|error processing user|cannot migrate|already exists)' 2>/dev/null || true)
# grep -c devuelve string; convertir a int defensivamente
if [[ "$conflict_lines" =~ ^[0-9]+$ ]]; then
conflicts=$conflict_lines
else
# Parser falló de forma inesperada — abortar defensivamente
echo "mas_syn2mas_migration: ERROR — no se pudo parsear el conteo de conflicts del dry-run (parser defensivo)" >&2
local dry_run_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":-1,\"users_migrated\":0,\"duration_s\":0}"
return 1
fi
echo "mas_syn2mas_migration: conflicts detectados en dry-run: ${conflicts} (max permitido: ${max_conflicts})" >&2
# =========================================================================
# PASO 3: Verificar threshold de conflicts
# =========================================================================
if [[ $conflicts -gt $max_conflicts ]]; then
echo "mas_syn2mas_migration: ABORTADO — conflicts (${conflicts}) > max-conflicts (${max_conflicts})" >&2
echo "Revisar: ${dry_run_log}" >&2
local dry_run_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
echo "{\"status\":\"aborted\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
return 2
fi
# =========================================================================
# PASO 4: Si no --apply, terminar aqui con status=ok (dry-run completado)
# =========================================================================
if [[ "$do_apply" == "false" ]]; then
echo "mas_syn2mas_migration: dry-run completado (${conflicts} conflicts). Revisar log y re-ejecutar con --apply." >&2
local dry_run_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
echo "{\"status\":\"ok\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":null,\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":0}"
return 0
fi
# =========================================================================
# PASO 5: Migracion REAL (--apply)
# =========================================================================
echo "mas_syn2mas_migration: ejecutando migracion REAL..." >&2
local apply_start
apply_start=$(date +%s)
local apply_exit=0
local apply_output
apply_output=$(ssh "$ssh_host" \
"docker exec '${mas_container}' mas-cli syn2mas \
--synapse-config '${container_config}'" \
2>&1) || apply_exit=$?
local apply_end
apply_end=$(date +%s)
local duration_s=$(( apply_end - apply_start ))
# Archivar log de apply
{
echo "# mas_syn2mas_migration apply"
echo "# ts=${ts} ssh-host=${ssh_host} container=${mas_container}"
echo "# synapse-config-path=${synapse_config_path}"
echo "# exit=${apply_exit} duration_s=${duration_s}"
echo "# ---"
printf '%s\n' "$apply_output"
} > "$apply_log_file"
chmod 0600 "$apply_log_file"
apply_log_path="$apply_log_file"
echo "mas_syn2mas_migration: apply exit=${apply_exit}, duration=${duration_s}s, log=${apply_log_file}" >&2
if [[ $apply_exit -ne 0 ]]; then
echo "mas_syn2mas_migration: ERROR — migracion real falló con exit ${apply_exit}" >&2
local dry_run_log_json apply_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
echo "{\"status\":\"error\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":0,\"duration_s\":${duration_s}}"
return 1
fi
# =========================================================================
# PASO 6: Postcondicion — comparar usuarios en MAS vs Synapse
# =========================================================================
echo "mas_syn2mas_migration: verificando postcondicion (usuarios MAS vs Synapse)..." >&2
local mas_user_count=0
local synapse_user_count=0
local users_migrated=0
local post_status="ok"
# Contar usuarios en MAS via mas-cli admin user list
local mas_count_raw
mas_count_raw=$(ssh "$ssh_host" \
"docker exec '${mas_container}' mas-cli manage list-users --json 2>/dev/null | jq length" \
2>/dev/null || echo "0")
if [[ "$mas_count_raw" =~ ^[0-9]+$ ]]; then
mas_user_count=$mas_count_raw
else
echo "mas_syn2mas_migration: ADVERTENCIA — no se pudo obtener conteo de usuarios MAS (output: ${mas_count_raw})" >&2
post_status="ok" # No abortar, solo advertir
fi
# Contar usuarios locales en Synapse via psql (excluyendo bots/AS)
# Intentamos obtener el count; si falla, continuamos sin abortar
local synapse_count_raw
synapse_count_raw=$(ssh "$ssh_host" \
"docker exec '${mas_container}' mas-cli syn2mas --synapse-config '${container_config}' --dry-run 2>&1 | grep -oE 'Found [0-9]+ users' | grep -oE '[0-9]+' | head -1" \
2>/dev/null || echo "0")
if [[ "$synapse_count_raw" =~ ^[0-9]+$ ]]; then
synapse_user_count=$synapse_count_raw
fi
users_migrated=$mas_user_count
# Si tenemos ambos counts y difieren significativamente, marcar como warning en log
if [[ $synapse_user_count -gt 0 && $mas_user_count -eq 0 ]]; then
echo "mas_syn2mas_migration: ADVERTENCIA — MAS reporta 0 usuarios pero Synapse tenia ${synapse_user_count}" >&2
post_status="error"
fi
echo "mas_syn2mas_migration: postcondicion: mas_users=${mas_user_count} synapse_users=${synapse_user_count} status=${post_status}" >&2
# =========================================================================
# PASO 7: Emitir JSON final
# =========================================================================
local dry_run_log_json apply_log_json
dry_run_log_json=$(printf '%s' "$dry_run_log" | jq -Rs '.')
apply_log_json=$(printf '%s' "$apply_log_file" | jq -Rs '.')
echo "{\"status\":\"${post_status}\",\"dry_run_log\":${dry_run_log_json},\"apply_log\":${apply_log_json},\"conflicts\":${conflicts},\"users_migrated\":${users_migrated},\"duration_s\":${duration_s}}"
return 0
}
# Ejecutar si se llama directamente (no sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
mas_syn2mas_migration "$@"
fi
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Tests para mas_syn2mas_migration
# Verifica arg parsing sin conectar al VPS real.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/mas_syn2mas_migration.sh"
PASS=0
FAIL=0
assert_exit() {
local test_name="$1" expected_exit="$2"
shift 2
local actual_exit=0
set +e
"$@" >/dev/null 2>&1
actual_exit=$?
set -e
if [[ "$actual_exit" == "$expected_exit" ]]; then
echo "PASS: $test_name"
((PASS++)) || true
else
echo "FAIL: $test_name — expected exit $expected_exit, got $actual_exit"
((FAIL++)) || true
fi
}
assert_stdout_contains() {
local test_name="$1" needle="$2"
shift 2
local output actual_exit=0
set +e
output=$("$@" 2>/dev/null)
actual_exit=$?
set -e
if echo "$output" | grep -q "$needle"; then
echo "PASS: $test_name"
((PASS++)) || true
else
echo "FAIL: $test_name — expected stdout to contain '$needle', got: $output"
((FAIL++)) || true
fi
}
# Test: aborta con error cuando faltan args obligatorios
assert_exit "aborta con error cuando faltan args obligatorios" 1 \
mas_syn2mas_migration
# Test: help no devuelve error
assert_exit "help no devuelve error" 0 \
mas_syn2mas_migration --help
# Test: argumento desconocido retorna exit 1
assert_exit "argumento desconocido retorna exit 1" 1 \
mas_syn2mas_migration --unknown-flag
# Test: max-conflicts invalido retorna exit 1
assert_exit "max-conflicts invalido retorna exit 1" 1 \
mas_syn2mas_migration \
--ssh-host fake-host \
--mas-container fake-container \
--synapse-config-path /fake/homeserver.yaml \
--log-dir "/tmp/test_mas_migration_$$" \
--max-conflicts "not-a-number"
# Test: help emite JSON valido con status=help
assert_stdout_contains "help emite JSON con status help" '"status":"help"' \
mas_syn2mas_migration --help
# Test: falta --ssh-host emite JSON con status=error
assert_stdout_contains "falta ssh-host emite JSON error" '"status":"error"' \
mas_syn2mas_migration \
--mas-container fake-container \
--synapse-config-path /fake/homeserver.yaml \
--log-dir "/tmp/test_mas_migration_$$"
# Test: falta --log-dir emite JSON con status=error
assert_stdout_contains "falta log-dir emite JSON error" '"status":"error"' \
mas_syn2mas_migration \
--ssh-host fake-host \
--mas-container fake-container \
--synapse-config-path /fake/homeserver.yaml
# Limpieza
rm -rf "/tmp/test_mas_migration_$$" 2>/dev/null || true
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
@@ -32,16 +32,16 @@ file_path: "bash/functions/infra/pre_commit_hook_install.sh"
source bash/functions/infra/pre_commit_hook_install.sh
# Instalar en el repo actual
pre_commit_hook_install /home/lucas/fn_registry
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
pre_commit_hook_install $HOME/fn_registry
# INSTALLED $HOME/fn_registry/.git/hooks/pre-commit
# Idempotente: segunda llamada no sobreescribe
pre_commit_hook_install /home/lucas/fn_registry
# SKIP /home/lucas/fn_registry/.git/hooks/pre-commit (already installed)
pre_commit_hook_install $HOME/fn_registry
# SKIP $HOME/fn_registry/.git/hooks/pre-commit (already installed)
# Forzar reinstalacion (hace backup del hook anterior)
pre_commit_hook_install /home/lucas/fn_registry --force
# INSTALLED /home/lucas/fn_registry/.git/hooks/pre-commit
pre_commit_hook_install $HOME/fn_registry --force
# INSTALLED $HOME/fn_registry/.git/hooks/pre-commit
```
## Notas
@@ -58,5 +58,5 @@ Si no puede localizar `fn_registry`, el hook imprime un aviso y sale con exit 0
Configurar `FN_REGISTRY_ROOT` en el perfil del shell para garantizar que el hook siempre encuentre el registry:
```bash
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
export FN_REGISTRY_ROOT=$HOME/fn_registry
```
+3 -3
View File
@@ -28,13 +28,13 @@ output: "Una linea TAB-separada '<app_name>\\t<absolute_dir_path>' en stdout. En
```bash
# Desde dentro de cpp/apps/chart_demo/
cd /home/lucas/fn_registry/cpp/apps/chart_demo
cd $HOME/fn_registry/cpp/apps/chart_demo
resolve_cpp_app_dir
# -> chart_demo\t/home/lucas/fn_registry/cpp/apps/chart_demo
# -> chart_demo\t$HOME/fn_registry/cpp/apps/chart_demo
# Con argumento explicito
resolve_cpp_app_dir registry_dashboard
# -> registry_dashboard\t/home/lucas/fn_registry/cpp/apps/registry_dashboard
# -> registry_dashboard\t$HOME/fn_registry/cpp/apps/registry_dashboard
# Capturar los dos campos
resolved=$(resolve_cpp_app_dir graph_explorer)
+1 -1
View File
@@ -7,7 +7,7 @@
resolve_cpp_app_dir() {
local app_arg="${1:-}"
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
_list_cpp_apps() {
ls "$root/apps/" 2>/dev/null | sed 's/^/ apps\//'
+1 -1
View File
@@ -39,7 +39,7 @@ echo "$result"
# {"files_transferred": 12, "total_size": "1.23 MB", "ssh_alias": "prod-server", "remote_dir": "/opt/apps/dag_engine"}
# Deploy con ruta absoluta local
rsync_deploy "/home/lucas/fn_registry/apps/myapp/" "myserver" "/opt/myapp"
rsync_deploy "$HOME/fn_registry/apps/myapp/" "myserver" "/opt/myapp"
```
## Notas
@@ -0,0 +1,73 @@
---
name: start_nordvpn_socks_bridge
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "start_nordvpn_socks_bridge([--port N] [--socks-host HOST] [--socks-port N] [--user U] [--pass P]) -> JSON"
description: "Levanta un proxy HTTP local sin auth que reenvía al servidor SOCKS5 de NordVPN con auth usando gost v3. Resuelve la limitación de Chrome, que no soporta SOCKS5-con-auth: el navegador apunta a http://127.0.0.1:<port> (sin auth) y el tráfico sale por NordVPN. Idempotente: si el puerto ya escucha, no relanza."
tags: [navegator, vpn, proxy, nordvpn, socks5, gost, chrome, cdp]
params:
- name: "--port"
desc: "Puerto HTTP local del bridge (default 8889)"
- name: "--socks-host"
desc: "Servidor SOCKS5 de NordVPN (default socks-nl1.nordvpn.com)"
- name: "--socks-port"
desc: "Puerto del servidor SOCKS5 (default 1080)"
- name: "--user"
desc: "Service username de NordVPN. Si se omite, lee NORDVPN_SOCKS_USER del entorno"
- name: "--pass"
desc: "Service password de NordVPN. Si se omite, lee NORDVPN_SOCKS_PASS del entorno"
output: "JSON en stdout: {proxy_url, pid, socks_host, status}. status puede ser 'running' (lanzado ahora) o 'already_running' (puerto ya escuchaba). Errores a stderr + exit 1."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/start_nordvpn_socks_bridge.sh"
---
## Ejemplo
```bash
# Con env vars (recomendado para scripts):
NORDVPN_SOCKS_USER=xxx NORDVPN_SOCKS_PASS=yyy \
bash bash/functions/infra/start_nordvpn_socks_bridge.sh \
--port 8889 \
--socks-host socks-nl1.nordvpn.com
# Salida (primera vez):
# {"proxy_url":"http://127.0.0.1:8889","pid":12345,"socks_host":"socks-nl1.nordvpn.com","status":"running"}
# Salida (idempotente, ya corría):
# {"proxy_url":"http://127.0.0.1:8889","pid":null,"socks_host":"socks-nl1.nordvpn.com","status":"already_running"}
# Luego Chrome (o el flujo CDP del navegator) apunta al bridge:
# chrome.exe --proxy-server=http://127.0.0.1:8889
# Verificar que el tráfico sale por NordVPN:
# curl -x http://127.0.0.1:8889 https://api.ipify.org
# -> 109.202.99.x (IP NordVPN NL, no la IP de casa)
```
## Cuando usarla
Cuando necesitas que Chrome (o cualquier app que solo acepta proxy HTTP sin auth) salga por NordVPN, pero NordVPN solo ofrece SOCKS5-con-auth. Chrome no soporta SOCKS5-with-authentication — este bridge actúa de intermediario sin auth local. Útil especialmente en el flujo CDP del navegator (cdp-cli + agente browser) cuando quieres que el browser de automatización salga con IP NordVPN para evadir DPI del ISP o geo-bloqueos, sin exponer las credenciales NordVPN al proceso del browser.
## Gotchas
- **NordVPN SOCKS5 exige service credentials**, no el usuario/contraseña de la cuenta NordVPN. Se obtienen en dashboard.nordvpn.com → Manual Setup → Service credentials.
- **Chrome no soporta SOCKS5-auth** nativamente (a diferencia de Firefox que sí). Por eso este bridge HTTP-sin-auth es necesario.
- **gost escucha en todas las interfaces** (`-L http://:PORT`). El puerto local NO tiene autenticación. No exponer en redes no confiables. Para bind solo a loopback cambiar el flag a `-L http://127.0.0.1:PORT` en el script si es necesario.
- **Servidores NordVPN SOCKS5 disponibles**: `socks-nl1..8.nordvpn.com`, `socks-de1..4.nordvpn.com`, `socks-us1..8.nordvpn.com`, etc. La lista completa en el dashboard de NordVPN.
- **Si gost no está instalado**: se descarga automáticamente `gost v3.0.0 linux amd64` a `~/.local/bin/gost`. Requiere curl y tar.
- **Log**: en `/tmp/nordvpn_socks_bridge_<port>.log`. Consultar si el bridge no arranca.
- **PID null en already_running**: cuando el puerto ya escuchaba, el PID del proceso no se recupera (habría que hacer `lsof`/`ss` para identificarlo).
- **Consumidor principal**: flujo `navegator`/CDP — ver `docs/capabilities/navegator.md`. El agente browser lanza este bridge antes de abrir Chrome con `--proxy-server=http://127.0.0.1:<port>`.
- **Gotcha invocación desde el Bash tool de Claude (exit 144)**: el script deja gost en background (`nohup ... & disown`); ese daemon retiene el pipe de stdout del tool → el harness mata el proceso con SIGSTKFLT (exit 144) AUNQUE el bridge SÍ arranca bien. Lanzar con `run_in_background:true` o redirigiendo todo (`>/tmp/x 2>&1 </dev/null`) para evitarlo. En terminal real (o `fn run` interactivo) no ocurre. Verificado 2026-05-30: el bridge queda corriendo y funcional pese al 144.
- **Windows→WSL**: si Chrome corre en Windows (chrome.exe) y gost en WSL2, Chrome alcanza `127.0.0.1:<port>` vía localhostForwarding de WSL2. Verificar con `curl -x http://127.0.0.1:<port> https://api.ipify.org` desde ambos lados.
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# start_nordvpn_socks_bridge — Levanta proxy HTTP local sin auth que reenvía a SOCKS5 NordVPN con auth via gost v3.
# Resuelve la limitacion de Chrome que no soporta SOCKS5-con-auth.
set -euo pipefail
# --- defaults ---
PORT=8889
SOCKS_HOST="socks-nl1.nordvpn.com"
SOCKS_PORT=1080
VPN_USER="${NORDVPN_SOCKS_USER:-}"
VPN_PASS="${NORDVPN_SOCKS_PASS:-}"
# --- parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--socks-host) SOCKS_HOST="$2"; shift 2 ;;
--socks-port) SOCKS_PORT="$2"; shift 2 ;;
--user) VPN_USER="$2"; shift 2 ;;
--pass) VPN_PASS="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# --- validate creds ---
if [[ -z "$VPN_USER" ]]; then
echo "error: NORDVPN_SOCKS_USER not set. Use --user or export NORDVPN_SOCKS_USER" >&2
exit 1
fi
if [[ -z "$VPN_PASS" ]]; then
echo "error: NORDVPN_SOCKS_PASS not set. Use --pass or export NORDVPN_SOCKS_PASS" >&2
exit 1
fi
LOG_FILE="/tmp/nordvpn_socks_bridge_${PORT}.log"
# --- check idempotencia: ya escucha? ---
if ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":null,\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"already_running\"}"
exit 0
fi
# --- asegurar gost ---
GOST_BIN=""
if command -v gost &>/dev/null; then
GOST_BIN="$(command -v gost)"
elif [[ -x "$HOME/.local/bin/gost" ]]; then
GOST_BIN="$HOME/.local/bin/gost"
else
echo "gost not found, downloading v3.0.0 linux amd64..." >&2
GOST_URL="https://github.com/go-gost/gost/releases/download/v3.0.0/gost_3.0.0_linux_amd64.tar.gz"
TMP_DIR="$(mktemp -d)"
curl -fsSL "$GOST_URL" -o "$TMP_DIR/gost.tar.gz" >&2
tar -xzf "$TMP_DIR/gost.tar.gz" -C "$TMP_DIR" >&2
mkdir -p "$HOME/.local/bin"
cp "$TMP_DIR/gost" "$HOME/.local/bin/gost"
chmod +x "$HOME/.local/bin/gost"
rm -rf "$TMP_DIR"
GOST_BIN="$HOME/.local/bin/gost"
echo "gost installed to $GOST_BIN" >&2
fi
# --- url-encode user y pass (puede tener caracteres especiales) ---
url_encode() {
python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"
}
ENC_USER="$(url_encode "$VPN_USER")"
ENC_PASS="$(url_encode "$VPN_PASS")"
# --- lanzar gost en background ---
nohup "$GOST_BIN" \
-L "http://:${PORT}" \
-F "socks5://${ENC_USER}:${ENC_PASS}@${SOCKS_HOST}:${SOCKS_PORT}" \
>"$LOG_FILE" 2>&1 &
GOST_PID=$!
disown $GOST_PID
# --- esperar ~2s y verificar que el puerto escucha ---
sleep 2
if ! ss -ltn 2>/dev/null | grep -q ":${PORT} "; then
echo "error: gost did not start. Last lines of $LOG_FILE:" >&2
tail -10 "$LOG_FILE" >&2
exit 1
fi
echo "{\"proxy_url\":\"http://127.0.0.1:${PORT}\",\"pid\":${GOST_PID},\"socks_host\":\"${SOCKS_HOST}\",\"status\":\"running\"}"
+68
View File
@@ -0,0 +1,68 @@
---
name: wg_client_install
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_client_install(config_path_or_stdin, [interface_name]) -> json"
description: "Device-side: instala wg0.conf en /etc/wireguard/, habilita systemd wg-quick@wg0, verifica handshake con hub. Idempotente. Acepta config por path o stdin (para pipes desde wg_client_config)."
tags: [wireguard, client, install, mesh, systemd]
uses_functions: [wg_install_bash_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: config_path_or_stdin
desc: "path al archivo .conf existente, o '-' para leer de stdin (compatible con pipe desde wg_client_config)"
- name: interface_name
desc: "nombre de la interfaz WireGuard (default: wg0). Determina /etc/wireguard/<iface>.conf y la unit systemd wg-quick@<iface>"
output: "JSON {status, interface, hub_endpoint, handshake_seen}. status: installed | already-configured | installed-no-handshake | installed-no-systemd"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_client_install.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wg_client_install.sh
# Desde pipe (caso más común en flow 0009):
wg_client_config_go_infra | jq -r '.INI' | wg_client_install -
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Desde archivo .conf generado previamente:
wg_client_install /tmp/peer_laptop.conf
# {"status":"installed","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Con interfaz personalizada:
wg_client_install /tmp/peer_laptop.conf wg1
# {"status":"installed","interface":"wg1","hub_endpoint":"203.0.113.1:51820","handshake_seen":true}
# Segunda ejecución con misma config (idempotente):
wg_client_install /tmp/peer_laptop.conf
# {"status":"already-configured","interface":"wg0","hub_endpoint":"203.0.113.1:51820","handshake_seen":false}
```
## Cuando usarla
Cuando necesites conectar un nuevo peer al mesh WireGuard en el flow 0009. Úsala justo después de `wg_client_config` (que genera el .conf) para instalarlo en el device peer. Es el paso final del onboarding de un nodo: config generada → instalada → verificada con handshake.
## Gotchas
- **Requiere root/sudo** para escribir en `/etc/wireguard/`, hacer `chmod 600`, y ejecutar `systemctl`. El operador debe tener `sudo` sin password para estos comandos, o ejecutar la función como root.
- **Idempotente por contenido**: si `/etc/wireguard/<iface>.conf` ya existe con el mismo contenido, retorna `status=already-configured` sin tocar nada. Si el contenido difiere, hace backup automático con timestamp antes de sobreescribir.
- **NetworkManager**: si NM gestiona la interfaz wg0, `wg-quick` puede fallar con conflicto. Solución: crear `/etc/NetworkManager/conf.d/99-wg.conf` con `[keyfile]\nunmanaged-devices=interface-name:wg0` y reiniciar NM antes de ejecutar esta función.
- **WSL2 sin systemd** (variantes antiguas o sin `/etc/wsl.conf` con `[boot] systemd=true`): `systemctl` no está disponible. La función detecta esto, emite `status=installed-no-systemd` con instrucciones en stderr para levantar la interfaz manualmente con `sudo wg-quick up wg0`. Para autostart en WSL2 sin systemd: añadir `sudo wg-quick up wg0` al final de `~/.bashrc`.
- **WSL2 con systemd**: kernel WSL2 >= 5.6 (default en distros recientes) incluye WireGuard built-in. Habilitar systemd en WSL2 con `[boot]\nsystemd=true` en `/etc/wsl.conf` y reiniciar WSL. Luego esta función funciona igual que en Linux nativo.
- **Android / Termux**: NO usar esta función. Termux no tiene systemd ni `/etc/wireguard/`. En Android usar la app WireGuard oficial (F-Droid / Play Store) e importar el .conf generado por `wg_client_config` directamente desde la app.
- **handshake_seen=false con status=installed-no-handshake**: la interfaz está activa pero el hub no ha respondido en 10s. No es un error fatal — puede tardar más si el hub está ocupado o hay NAT traversal pendiente. Verificar: endpoint accesible por UDP, hub corriendo con `wg show`, claves public/preshared coincidentes.
- Los logs van siempre a stderr con prefijo `[wg_client_install]`; stdout es exclusivamente el JSON de resultado.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# wg_client_install — Device-side: instala wg0.conf en /etc/wireguard/, habilita
# systemd wg-quick@<iface>, verifica handshake con hub. Idempotente.
# Acepta config por path o stdin ("-").
# Exit 0 = éxito (installed o already-configured), 1 = error fatal.
wg_client_install() {
local config_src="${1:--}"
local iface="${2:-wg0}"
local conf_dest="/etc/wireguard/${iface}.conf"
local config_content="" hub_endpoint="" handshake_seen="false"
_wg_ci_log() { echo "[wg_client_install] $*" >&2; }
# ── Prereq: wg debe estar instalado ──────────────────────────────────────
if ! command -v wg &>/dev/null; then
_wg_ci_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
return 1
fi
# ── Leer contenido del .conf ──────────────────────────────────────────────
if [[ "${config_src}" == "-" ]]; then
_wg_ci_log "Leyendo config desde stdin"
config_content=$(cat) || { _wg_ci_log "ERROR: fallo al leer stdin"; return 1; }
elif [[ -f "${config_src}" ]]; then
_wg_ci_log "Leyendo config desde ${config_src}"
config_content=$(cat "${config_src}") || { _wg_ci_log "ERROR: fallo al leer ${config_src}"; return 1; }
else
_wg_ci_log "ERROR: '${config_src}' no es un path existente ni '-' (stdin)"
return 1
fi
if [[ -z "${config_content}" ]]; then
_wg_ci_log "ERROR: contenido de config vacío"
return 1
fi
# ── Extraer endpoint del hub para incluirlo en el JSON de salida ──────────
hub_endpoint=$(printf '%s\n' "${config_content}" | grep -m1 '^Endpoint\s*=' | sed 's/.*=\s*//' | tr -d '[:space:]' || true)
# ── Idempotencia: comparar con conf existente ─────────────────────────────
if [[ -f "${conf_dest}" ]]; then
local existing_content
existing_content=$(sudo cat "${conf_dest}" 2>/dev/null || cat "${conf_dest}" 2>/dev/null || true)
if [[ "${existing_content}" == "${config_content}" ]]; then
_wg_ci_log "Configuración idéntica ya presente en ${conf_dest}; nada que hacer"
printf '{"status":"already-configured","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
# Contenido difiere → backup + rewrite
local backup="${conf_dest}.bak.$(date +%Y%m%d%H%M%S)"
_wg_ci_log "Configuración existente difiere; backup → ${backup}"
sudo cp "${conf_dest}" "${backup}" \
|| { _wg_ci_log "ERROR: no se pudo hacer backup de ${conf_dest}"; return 1; }
fi
# ── Crear directorio y escribir conf ─────────────────────────────────────
sudo mkdir -p "/etc/wireguard" \
|| { _wg_ci_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
printf '%s\n' "${config_content}" | sudo tee "${conf_dest}" >/dev/null \
|| { _wg_ci_log "ERROR: no se pudo escribir ${conf_dest}"; return 1; }
sudo chmod 600 "${conf_dest}" \
|| { _wg_ci_log "WARN: no se pudo chmod 600 ${conf_dest}"; }
_wg_ci_log "Config escrita en ${conf_dest} (chmod 600)"
# ── Habilitar + arrancar systemd unit ─────────────────────────────────────
if ! command -v systemctl &>/dev/null; then
_wg_ci_log "WARN: systemctl no disponible."
_wg_ci_log " En WSL2 sin systemd: ejecuta 'sudo wg-quick up ${iface}' manualmente."
_wg_ci_log " Para autostart en WSL2: añade 'sudo wg-quick up ${iface}' a ~/.bashrc o usa WSL2 con systemd habilitado."
printf '{"status":"installed-no-systemd","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
_wg_ci_log "Habilitando y arrancando wg-quick@${iface}"
if ! sudo systemctl enable --now "wg-quick@${iface}" 2>&1 | tee /dev/stderr >&2; then
_wg_ci_log "ERROR: systemctl enable --now wg-quick@${iface} falló."
_wg_ci_log " En WSL2: asegúrate de tener kernel >= 5.6 y systemd habilitado (/etc/wsl.conf: [boot] systemd=true)."
_wg_ci_log " Si NetworkManager gestiona ${iface}: añade 'unmanaged-devices=interface-name:${iface}' a /etc/NetworkManager/conf.d/99-wg.conf"
return 1
fi
_wg_ci_log "wg-quick@${iface} habilitado y activo"
# ── Esperar handshake (hasta 10 s) ────────────────────────────────────────
local deadline=$(( $(date +%s) + 10 ))
_wg_ci_log "Esperando handshake en ${iface} (timeout 10s)..."
while [[ $(date +%s) -lt ${deadline} ]]; do
local hs_output
hs_output=$(sudo wg show "${iface}" latest-handshakes 2>/dev/null || true)
# latest-handshakes devuelve "<pubkey> <unix_ts>"; ts > 0 = handshake visto
if printf '%s\n' "${hs_output}" | awk '{print $2}' | grep -qE '^[1-9][0-9]+$'; then
handshake_seen="true"
_wg_ci_log "Handshake confirmado en ${iface}"
break
fi
sleep 1
done
if [[ "${handshake_seen}" == "false" ]]; then
_wg_ci_log "WARN: timeout esperando handshake en ${iface}. La interfaz está activa pero el hub no ha respondido aún."
_wg_ci_log " Verifica: endpoint accesible, hub corriendo, claves correctas."
printf '{"status":"installed-no-handshake","interface":"%s","hub_endpoint":"%s","handshake_seen":false}\n' \
"${iface}" "${hub_endpoint}"
return 0
fi
printf '{"status":"installed","interface":"%s","hub_endpoint":"%s","handshake_seen":true}\n' \
"${iface}" "${hub_endpoint}"
return 0
}
+66
View File
@@ -0,0 +1,66 @@
---
name: wg_hub_setup
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_hub_setup(private_key, subnet_cidr, listen_port) -> json"
description: "Configura el host como hub WireGuard (servidor). Crea /etc/wireguard/wg0.conf con clave privada + IP pool + ListenPort. Abre UDP en firewall (ufw o iptables), habilita ip_forward persistente en /etc/sysctl.d/99-wireguard.conf, persiste y arranca systemd unit wg-quick@wg0. Idempotente: misma PrivateKey = no-op; PrivateKey distinta = backup + rewrite."
tags: [wireguard, hub, infra, mesh, systemd]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: private_key
desc: "base64 WG private key del hub (44 chars, generada por wg_keygen o `wg genkey`)"
- name: subnet_cidr
desc: "subnet hub con bits del host, ej. 10.42.0.1/24. El hub recibe la .1"
- name: listen_port
desc: "UDP port donde escucha WireGuard (default 51820, rango 1024-65535)"
output: "JSON {status, config_path, interface, hub_ip}. status: configured | reconfigured | already-configured"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_hub_setup.sh"
---
## Ejemplo
```bash
# Generar clave (o usar wg_keygen del registry)
PRIVKEY=$(wg genkey)
source bash/functions/infra/wg_hub_setup.sh
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
# {"status":"configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
# Segunda ejecución con la misma clave → no-op
wg_hub_setup "$PRIVKEY" "10.42.0.1/24" 51820
# {"status":"already-configured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
# Cambiar clave → backup de conf anterior + rewrite
wg_hub_setup "$NUEVA_PRIVKEY" "10.42.0.1/24" 51820
# {"status":"reconfigured","config_path":"/etc/wireguard/wg0.conf","interface":"wg0","hub_ip":"10.42.0.1"}
```
## Cuando usarla
Cuando necesites convertir un VPS/host en el nodo central (hub) de una red mesh WireGuard. Úsala inmediatamente después de `wg_install` para dejar el hub listo para recibir peers. El hub escucha en un puerto UDP público; los peers se conectan a él con su propia clave y la AllowedIPs del hub.
## Gotchas
- Requiere `sudo` con NOPASSWD para: `tee /etc/wireguard/`, `chmod`, `sysctl`, `iptables`/`ufw`, `systemctl`. Configurar antes en sudoers.
- NUNCA reusar la misma `private_key` entre hubs distintos. Cada hub tiene su propio par de claves independiente.
- El bloque `PostUp`/`PostDown` usa `eth0` como interfaz de salida para NAT. En VPS con interfaz distinta (ens3, enp3s0) editar `/etc/wireguard/wg0.conf` manualmente antes de reiniciar.
- Conflicto de subnet con docker0 si usas 172.17.0.0/16. Evitar solapamiento — usar 10.42.x.x o 192.168.200.x para WireGuard.
- `systemd-resolved` en VPS Ubuntu puede interferir con resolución DNS cuando WireGuard está activo si el conf añade `DNS =`. Esta función NO setea DNS para evitar el problema — configurarlo a nivel peer si se necesita.
- Si `systemctl start wg-quick@wg0` falla, revisar logs con `journalctl -u wg-quick@wg0 -n 50`.
- En entornos cloud (AWS/GCP/Azure) el security group / firewall de red del proveedor también debe abrir el puerto UDP, independientemente de ufw/iptables local.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# wg_hub_setup — Configura el host como hub WireGuard (servidor central).
# Crea /etc/wireguard/wg0.conf con [Interface] block, abre UDP en firewall,
# habilita ip_forward persistente, arranca y verifica wg-quick@wg0.
# Idempotente: si el conf existe con la misma PrivateKey -> no-op.
# Emite JSON a stdout. Logs a stderr con prefijo [wg_hub_setup].
# Exit 0 = éxito, 1 = fallo.
wg_hub_setup() {
local private_key="${1:-}"
local subnet_cidr="${2:-10.42.0.1/24}"
local listen_port="${3:-51820}"
_wg_hub_log() { echo "[wg_hub_setup] $*" >&2; }
# ── Validación de entradas ──────────────────────────────────────────────
# private_key: base64 estándar de 44 caracteres (32 bytes)
if [[ -z "${private_key}" ]]; then
_wg_hub_log "ERROR: private_key requerida (base64 44 chars, generada por wg genkey)"
return 1
fi
if ! [[ "${private_key}" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then
_wg_hub_log "ERROR: private_key no parece base64 válida (se esperan 44 chars terminando en '=')"
return 1
fi
# subnet_cidr: 10.x.x.x/nn
if ! [[ "${subnet_cidr}" =~ ^10\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
_wg_hub_log "ERROR: subnet_cidr debe ser 10.x.x.x/nn, recibido: '${subnet_cidr}'"
return 1
fi
# listen_port: 1024-65535
if ! [[ "${listen_port}" =~ ^[0-9]+$ ]] || (( listen_port < 1024 || listen_port > 65535 )); then
_wg_hub_log "ERROR: listen_port debe ser un entero entre 1024 y 65535, recibido: '${listen_port}'"
return 1
fi
# ── Verificar que wireguard-tools esté instalado ────────────────────────
if ! command -v wg &>/dev/null; then
_wg_hub_log "ERROR: 'wg' no encontrado. Ejecuta wg_install primero."
return 1
fi
if ! command -v wg-quick &>/dev/null; then
_wg_hub_log "ERROR: 'wg-quick' no encontrado. Instala wireguard-tools."
return 1
fi
# ── Extraer hub_ip (parte sin CIDR prefix) y determinar config_path ────
local hub_ip="${subnet_cidr%%/*}"
local config_path="/etc/wireguard/wg0.conf"
local interface="wg0"
local action_status=""
# ── Idempotencia: comparar PrivateKey existente ─────────────────────────
if [[ -f "${config_path}" ]]; then
local existing_key
existing_key=$(sudo grep -E '^\s*PrivateKey\s*=' "${config_path}" 2>/dev/null \
| head -n1 | sed 's/.*=\s*//')
if [[ "${existing_key}" == "${private_key}" ]]; then
_wg_hub_log "Config existente con misma PrivateKey — no-op (status=already-configured)"
printf '{"status":"already-configured","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
"${config_path}" "${interface}" "${hub_ip}"
return 0
else
_wg_hub_log "Config existente con PrivateKey DIFERENTE — haciendo backup y reescribiendo"
local backup_path="${config_path}.bak.$(date +%Y%m%d%H%M%S)"
sudo cp "${config_path}" "${backup_path}" \
|| { _wg_hub_log "ERROR: no se pudo hacer backup en ${backup_path}"; return 1; }
_wg_hub_log "Backup guardado en ${backup_path}"
action_status="reconfigured"
fi
else
action_status="configured"
fi
# ── Asegurar que /etc/wireguard existe con permisos correctos ───────────
if [[ ! -d /etc/wireguard ]]; then
sudo mkdir -p /etc/wireguard \
|| { _wg_hub_log "ERROR: no se pudo crear /etc/wireguard"; return 1; }
sudo chmod 700 /etc/wireguard
_wg_hub_log "Directorio /etc/wireguard creado"
fi
# ── Escribir /etc/wireguard/wg0.conf ────────────────────────────────────
_wg_hub_log "Escribiendo ${config_path} (Address=${subnet_cidr}, ListenPort=${listen_port})"
sudo tee "${config_path}" > /dev/null <<EOF
[Interface]
Address = ${subnet_cidr}
ListenPort = ${listen_port}
PrivateKey = ${private_key}
SaveConfig = false
# NAT: permite que los peers accedan a internet via este hub (opcional, comentar si no se desea)
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
EOF
if [[ $? -ne 0 ]]; then
_wg_hub_log "ERROR: no se pudo escribir ${config_path}"
return 1
fi
sudo chmod 600 "${config_path}" \
|| { _wg_hub_log "ERROR: chmod 600 ${config_path} falló"; return 1; }
_wg_hub_log "Permisos 600 aplicados a ${config_path}"
# ── Habilitar ip_forward persistente ────────────────────────────────────
local sysctl_file="/etc/sysctl.d/99-wireguard.conf"
if [[ ! -f "${sysctl_file}" ]] || ! grep -q "net.ipv4.ip_forward" "${sysctl_file}" 2>/dev/null; then
_wg_hub_log "Habilitando ip_forward en ${sysctl_file}"
echo "net.ipv4.ip_forward = 1" | sudo tee "${sysctl_file}" > /dev/null \
|| { _wg_hub_log "ERROR: no se pudo escribir ${sysctl_file}"; return 1; }
fi
sudo sysctl -p "${sysctl_file}" >&2 \
|| _wg_hub_log "WARN: sysctl -p falló (puede ignorarse si el kernel ya tiene ip_forward=1)"
# ── Abrir puerto en firewall ─────────────────────────────────────────────
if command -v ufw &>/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
_wg_hub_log "ufw activo — abriendo UDP/${listen_port}"
sudo ufw allow "${listen_port}/udp" >&2 \
|| _wg_hub_log "WARN: ufw allow ${listen_port}/udp falló (verificar manualmente)"
elif command -v iptables &>/dev/null; then
_wg_hub_log "ufw inactivo — usando iptables para abrir UDP/${listen_port}"
sudo iptables -C INPUT -p udp --dport "${listen_port}" -j ACCEPT 2>/dev/null \
|| sudo iptables -A INPUT -p udp --dport "${listen_port}" -j ACCEPT >&2 \
|| _wg_hub_log "WARN: iptables INPUT rule falló (verificar manualmente)"
else
_wg_hub_log "WARN: ni ufw ni iptables disponibles — abre el puerto ${listen_port}/udp manualmente"
fi
# ── Detener interfaz si estaba corriendo (para aplicar nueva config) ────
if sudo wg show "${interface}" &>/dev/null 2>&1; then
_wg_hub_log "Interfaz ${interface} activa — deteniendo antes de reconfigurar"
sudo systemctl stop "wg-quick@${interface}" 2>/dev/null \
|| sudo wg-quick down "${interface}" 2>/dev/null \
|| _wg_hub_log "WARN: no se pudo detener ${interface} (puede que no estuviera activa)"
fi
# ── Habilitar y arrancar wg-quick@wg0 ────────────────────────────────────
_wg_hub_log "Habilitando systemd unit wg-quick@${interface}"
sudo systemctl enable "wg-quick@${interface}" >&2 \
|| { _wg_hub_log "ERROR: systemctl enable wg-quick@${interface} falló"; return 1; }
_wg_hub_log "Arrancando wg-quick@${interface}"
sudo systemctl start "wg-quick@${interface}" >&2 \
|| { _wg_hub_log "ERROR: systemctl start wg-quick@${interface} falló"; return 1; }
# ── Verificar que la interfaz está UP ────────────────────────────────────
local retries=5
local up=0
for (( i=0; i<retries; i++ )); do
if sudo wg show "${interface}" &>/dev/null 2>&1; then
up=1
break
fi
sleep 1
done
if [[ "${up}" -eq 0 ]]; then
_wg_hub_log "ERROR: 'wg show ${interface}' falló tras ${retries}s — la interfaz no arrancó"
return 1
fi
_wg_hub_log "Interfaz ${interface} UP (status=${action_status})"
printf '{"status":"%s","config_path":"%s","interface":"%s","hub_ip":"%s"}\n' \
"${action_status}" "${config_path}" "${interface}" "${hub_ip}"
return 0
}
+51
View File
@@ -0,0 +1,51 @@
---
name: wg_install
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_install() -> json"
description: "Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch). Idempotente. Carga modulo kernel. Emite JSON con distro detectada y version instalada."
tags: [wireguard, install, infra, mesh, deploy]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params: []
output: "JSON {status, distro, version}. status=installed o already-present."
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/infra/wg_install.sh"
---
## Ejemplo
```bash
source bash/functions/infra/wg_install.sh
wg_install
# {"status":"installed","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
# Si ya está instalado:
wg_install
# {"status":"already-present","distro":"ubuntu","version":"wireguard-tools 1.0.20210914"}
```
## Cuando usarla
Cuando necesites asegurarte de que wireguard-tools está disponible en un host antes de configurar un peer o hub WireGuard. Úsala como paso previo en pipelines de bootstrapping de nodos mesh (flow wireguard).
## Gotchas
- Requiere `sudo` con NOPASSWD para apt-get/dnf/pacman y para modprobe. El operador debe haberlo configurado antes.
- `modprobe wireguard` puede fallar en kernels < 5.6 sin DKMS instalado (wireguard-dkms). La función lo trata como advertencia, no como error fatal — la instalación de las herramientas igual se completa.
- En RHEL/CentOS instala `epel-release` automáticamente antes de wireguard-tools.
- Distros no reconocidas en `/etc/os-release ID` producen exit 1 con mensaje de error explícito en stderr.
- Los logs van siempre a stderr con prefijo `[wg_install]`; stdout es exclusivamente el JSON de resultado.
## Capability growth log
<!-- Rellenar solo cuando haya version bump real -->
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# wg_install — Instala wireguard + wireguard-tools en Linux (debian/ubuntu/fedora/arch).
# Idempotente: si wg ya está instalado emite JSON con status=already-present y sale.
# Carga módulo kernel wireguard. Emite JSON a stdout. Logs a stderr con prefijo [wg_install].
# Exit 0 = éxito, 1 = fallo.
wg_install() {
local distro="" version="" status=""
_wg_log() { echo "[wg_install] $*" >&2; }
# Detectar distro via /etc/os-release
if [[ -f /etc/os-release ]]; then
distro=$(. /etc/os-release && echo "${ID:-unknown}")
else
_wg_log "ERROR: /etc/os-release no encontrado; no se puede detectar distro"
return 1
fi
_wg_log "Distro detectada: ${distro}"
# Comprobar si wg ya está instalado (idempotencia)
if command -v wg &>/dev/null; then
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
_wg_log "wireguard-tools ya presente (${version}); cargando módulo kernel"
# Intentar cargar módulo igualmente (no fatal)
sudo modprobe wireguard 2>/dev/null || true
printf '{"status":"already-present","distro":"%s","version":"%s"}\n' "${distro}" "${version}"
return 0
fi
# Instalar según distro
case "${distro}" in
debian|ubuntu|linuxmint|pop|kali|raspbian)
_wg_log "Usando apt-get (${distro})"
sudo apt-get update -y >&2 || { _wg_log "ERROR: apt-get update falló"; return 1; }
sudo apt-get install -y wireguard wireguard-tools >&2 \
|| { _wg_log "ERROR: apt-get install wireguard falló"; return 1; }
;;
fedora)
_wg_log "Usando dnf (fedora)"
sudo dnf install -y wireguard-tools >&2 \
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
;;
rhel|centos|rocky|almalinux)
_wg_log "Usando dnf (rhel/centos/rocky/alma)"
sudo dnf install -y epel-release >&2 || true
sudo dnf install -y wireguard-tools >&2 \
|| { _wg_log "ERROR: dnf install wireguard-tools falló"; return 1; }
;;
arch|manjaro|endeavouros)
_wg_log "Usando pacman (arch)"
sudo pacman -S --noconfirm wireguard-tools >&2 \
|| { _wg_log "ERROR: pacman install wireguard-tools falló"; return 1; }
;;
*)
_wg_log "ERROR: distro '${distro}' no soportada (soportadas: debian/ubuntu/fedora/rhel/arch)"
return 1
;;
esac
# Verificar instalación
if ! command -v wg &>/dev/null; then
_wg_log "ERROR: 'wg' no encontrado tras la instalación"
return 1
fi
version=$(wg --version 2>/dev/null | head -n1 || echo "unknown")
_wg_log "wireguard-tools instalado: ${version}"
# Cargar módulo kernel (no fatal: kernels >=5.6 lo incluyen built-in)
if sudo modprobe wireguard 2>/dev/null; then
_wg_log "Módulo kernel wireguard cargado"
else
_wg_log "WARN: modprobe wireguard falló (puede estar built-in en el kernel o requerir DKMS)"
fi
status="installed"
printf '{"status":"%s","distro":"%s","version":"%s"}\n' "${status}" "${distro}" "${version}"
return 0
}
+79
View File
@@ -0,0 +1,79 @@
---
name: wg_status
kind: function
lang: bash
domain: infra
version: "1.0.0"
purity: impure
signature: "wg_status([interface_name]) -> json"
description: "Parsea `wg show <iface> dump` a JSON estructurado con peers, handshake age, status (online/stale/never), bytes rx/tx. Resuelve device_id desde comentarios en wg0.conf. Para dashboards (agents_dashboard Mesh panel)."
tags: [wireguard, status, observability, json, infra]
params:
- name: interface_name
desc: "Nombre de la interface WireGuard (default wg0)"
output: "JSON con interface info + array de peers. Cada peer incluye public_key, device_id (de comentario # DeviceID:<id> en wg0.conf), endpoint, allowed_ips, latest_handshake_unix, latest_handshake_ago_s, rx_bytes, tx_bytes, persistent_keepalive, status (online/stale/never)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "interface con 2 peers online y stale"
- "interface sin peers devuelve array vacio"
- "interface inexistente devuelve error JSON"
- "WG_FAKE_DUMP carga dump de archivo"
test_file_path: "bash/functions/infra/wg_status_test.sh"
file_path: "bash/functions/infra/wg_status.sh"
---
## Ejemplo
```bash
# Estado real de wg0
source bash/functions/infra/wg_status.sh
wg_status | jq .
# Interface distinta
wg_status wg1 | jq .peers[].status
# Sin sudo real (testing / CI)
WG_FAKE_DUMP=bash/functions/infra/wg_status_test_dump.tsv wg_status wg0 | jq .
```
Salida representativa:
```json
{
"interface": "wg0",
"public_key": "abcXYZ123...",
"listen_port": "51820",
"peers": [
{
"public_key": "peerKey1...",
"device_id": "pc-aurgi",
"endpoint": "1.2.3.4:54321",
"allowed_ips": ["10.42.0.10/32"],
"latest_handshake_unix": 1716000000,
"latest_handshake_ago_s": 42,
"rx_bytes": 12345,
"tx_bytes": 67890,
"persistent_keepalive": 25,
"status": "online"
}
]
}
```
## Cuando usarla
Cuando necesites saber el estado del mesh WireGuard desde un script, dashboard o agente. Usa antes de mostrar el panel Mesh en `agents_dashboard`. Llama cada N segundos para polling ligero desde shell sin depender de la API de WireGuard.
## Gotchas
- Requiere `CAP_NET_ADMIN` / root: `wg show` falla sin permisos. En produccion ejecutar via `sudo -n wg show wg0 dump` o dar permiso al binario. Para tests sin sudo: `WG_FAKE_DUMP=<path>` carga el dump desde archivo.
- `listen_port` se devuelve como string (tal como lo emite `wg show dump`). El campo es `"0"` si wg no esta activo pero la interface existe.
- `device_id` queda `""` si no hay comentario `# DeviceID:<id>` antes del `[Peer]` correspondiente en `/etc/wireguard/<iface>.conf`.
- Status `stale` cubre desde 180s hasta cualquier valor mayor. No hay distincion entre "hace 5 min" y "hace 3 dias" — ambos son `stale`. Para un threshold mas fino, usar `latest_handshake_ago_s` directamente.
- Si `/etc/wireguard/<iface>.conf` no existe o no es legible, `device_id` sera `""` para todos los peers (la funcion no falla, solo omite el lookup).
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# wg_status — Parsea `wg show <iface> dump` a JSON estructurado con peers,
# handshake age, status (online/stale/never), bytes rx/tx.
# Resuelve device_id desde comentarios # DeviceID:<id> en wg0.conf.
#
# Usage:
# wg_status [interface_name] # default: wg0
#
# Env:
# WG_FAKE_DUMP=<path> # lee dump de archivo en vez de llamar wg show (para tests)
wg_status() {
local iface="${1:-wg0}"
local conf="${WG_FAKE_CONF:-/etc/wireguard/${iface}.conf}"
local now
now=$(date +%s)
# --- obtener dump (real o fake) ---
local dump
if [[ -n "${WG_FAKE_DUMP:-}" ]]; then
if [[ ! -f "$WG_FAKE_DUMP" ]]; then
printf '{"error":"WG_FAKE_DUMP file not found: %s"}\n' "$WG_FAKE_DUMP"
return 1
fi
dump=$(cat "$WG_FAKE_DUMP")
else
if ! command -v wg &>/dev/null; then
printf '{"error":"wg command not found"}\n'
return 1
fi
if ! dump=$(wg show "$iface" dump 2>&1); then
if echo "$dump" | grep -qi "no such device\|does not exist\|unable to access interface"; then
printf '{"error":"interface not found"}\n'
return 1
fi
printf '{"error":"%s"}\n' "$(echo "$dump" | head -n1 | sed 's/"/\\"/g')"
return 1
fi
fi
# --- primera linea: info de la propia interface ---
# formato: <private_key>\t<public_key>\t<listen_port>\t<fwmark>
local iface_line
iface_line=$(echo "$dump" | head -n1)
local iface_pubkey iface_port
iface_pubkey=$(echo "$iface_line" | awk -F'\t' '{print $2}')
iface_port=$(echo "$iface_line" | awk -F'\t' '{print $3}')
# --- leer DeviceID map desde wg0.conf ---
# Busca patron:
# # DeviceID:<id>
# [Peer]
# PublicKey = <pk>
# Producimos pares "pk\tdevice_id" en un archivo temporal para lookup via awk
local device_map
device_map=$(awk '
/^#[[:space:]]*DeviceID:/ {
split($0, a, "DeviceID:")
did = a[2]
gsub(/^[[:space:]]+|[[:space:]]+$/, "", did)
pending_did = did
}
/^\[Peer\]/ {
in_peer = 1
}
in_peer && /^PublicKey[[:space:]]*=/ {
pk = $0
sub(/^PublicKey[[:space:]]*=[[:space:]]*/, "", pk)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", pk)
if (pending_did != "") {
print pk "\t" pending_did
pending_did = ""
}
in_peer = 0
}
' "$conf" 2>/dev/null)
# --- parsear peers (lineas 2..N del dump) ---
# formato peer: <public_key>\t<preshared_key>\t<endpoint>\t<allowed_ips>\t<latest_handshake>\t<rx_bytes>\t<tx_bytes>\t<persistent_keepalive>
local peers_json
peers_json=$(echo "$dump" | tail -n +2 | awk -v now="$now" -v dmap="$device_map" '
BEGIN {
# construir lookup device_id
n = split(dmap, lines, "\n")
for (i = 1; i <= n; i++) {
if (lines[i] != "") {
split(lines[i], parts, "\t")
pk_to_did[parts[1]] = parts[2]
}
}
first = 1
printf "["
}
NF >= 7 {
pk = $1
endpoint = $3
allowed = $4
hs = $5 + 0
rx = $6 + 0
tx = $7 + 0
ka = $8
# device_id lookup
did = (pk in pk_to_did) ? pk_to_did[pk] : ""
# handshake age y status
if (hs == 0) {
ago = 0
status = "never"
} else {
ago = now - hs
if (ago < 180) status = "online"
else if (ago < 86400) status = "stale"
else status = "stale"
}
# persistent_keepalive
ka_val = (ka == "off" || ka == "") ? 0 : ka + 0
# endpoint null si "(none)"
ep_val = (endpoint == "(none)") ? "null" : "\"" endpoint "\""
# allowed_ips array
n_ips = split(allowed, ips_arr, ",")
ips_json = "["
for (j = 1; j <= n_ips; j++) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", ips_arr[j])
ips_json = ips_json "\"" ips_arr[j] "\""
if (j < n_ips) ips_json = ips_json ","
}
ips_json = ips_json "]"
if (!first) printf ","
first = 0
printf "{"
printf "\"public_key\":\"%s\"", pk
printf ",\"device_id\":\"%s\"", did
printf ",\"endpoint\":%s", ep_val
printf ",\"allowed_ips\":%s", ips_json
printf ",\"latest_handshake_unix\":%d", hs
printf ",\"latest_handshake_ago_s\":%d",ago
printf ",\"rx_bytes\":%d", rx
printf ",\"tx_bytes\":%d", tx
printf ",\"persistent_keepalive\":%d", ka_val
printf ",\"status\":\"%s\"", status
printf "}"
}
END { printf "]" }
' FS='\t')
# --- output final ---
printf '{"interface":"%s","public_key":"%s","listen_port":%s,"peers":%s}\n' \
"$iface" "$iface_pubkey" "$iface_port" "$peers_json"
}
# Permitir invocacion directa: bash wg_status.sh [iface]
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
wg_status "$@"
fi
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Tests para wg_status
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/wg_status.sh"
PASS=0
FAIL=0
assert_contains() {
local test_name="$1" needle="$2" haystack="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_not_contains() {
local test_name="$1" needle="$2" haystack="$3"
if ! echo "$haystack" | grep -qF "$needle"; then
echo "PASS: $test_name"
PASS=$((PASS+1))
else
echo "FAIL: $test_name — expected NOT to contain '$needle'"
echo " got: $haystack"
FAIL=$((FAIL+1))
fi
}
# --- fixtures ---
FAKE_DUMP=$(mktemp)
FAKE_DUMP_EMPTY=$(mktemp)
FAKE_CONF=$(mktemp)
trap 'rm -f "$FAKE_DUMP" "$FAKE_DUMP_EMPTY" "$FAKE_CONF"' EXIT
NOW=$(date +%s)
HS_ONLINE=$(( NOW - 60 )) # 60s ago → online
HS_STALE=$(( NOW - 500 )) # 500s ago → stale
# dump con 2 peers (tabs como separador)
printf '%s\n' \
"privKeyBase64== ifacePubKey== 51820 off" \
"peerKey1== (none) 1.2.3.4:54321 10.42.0.10/32 ${HS_ONLINE} 12345 67890 25" \
"peerKey2== (none) 5.6.7.8:12345 10.42.0.20/32 ${HS_STALE} 111 222 0" \
> "$FAKE_DUMP"
# dump vacío (solo línea de interface, sin peers)
printf '%s\n' "privKeyBase64== ifacePubKey== 51820 off" > "$FAKE_DUMP_EMPTY"
# conf con DeviceID comments
cat > "$FAKE_CONF" <<'CONF'
[Interface]
PrivateKey = privKeyBase64==
Address = 10.42.0.1/24
ListenPort = 51820
# DeviceID:pc-aurgi
[Peer]
PublicKey = peerKey1==
AllowedIPs = 10.42.0.10/32
# DeviceID:home-wsl
[Peer]
PublicKey = peerKey2==
AllowedIPs = 10.42.0.20/32
CONF
# --- Test: interface con 2 peers online y stale ---
result=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "interface con 2 peers online y stale" '"interface":"wg0"' "$result"
assert_contains "interface con 2 peers online y stale" '"listen_port":51820' "$result"
assert_contains "interface con 2 peers online y stale" '"public_key":"ifacePubKey=="' "$result"
assert_contains "interface con 2 peers online y stale" '"status":"online"' "$result"
assert_contains "interface con 2 peers online y stale" '"status":"stale"' "$result"
assert_contains "interface con 2 peers online y stale" '"device_id":"pc-aurgi"' "$result"
assert_contains "interface con 2 peers online y stale" '"device_id":"home-wsl"' "$result"
assert_contains "interface con 2 peers online y stale" '"rx_bytes":12345' "$result"
assert_contains "interface con 2 peers online y stale" '"persistent_keepalive":25' "$result"
# --- Test: interface sin peers devuelve array vacio ---
result_empty=$(WG_FAKE_DUMP="$FAKE_DUMP_EMPTY" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "interface sin peers devuelve array vacio" '"peers":[]' "$result_empty"
assert_not_contains "interface sin peers devuelve array vacio" '"error"' "$result_empty"
# --- Test: interface inexistente devuelve error JSON ---
result_err=$(wg_status nonexistent_iface_xyz 2>/dev/null || true)
assert_contains "interface inexistente devuelve error JSON" '"error"' "$result_err"
# --- Test: WG_FAKE_DUMP carga dump de archivo ---
result_fake=$(WG_FAKE_DUMP="$FAKE_DUMP" WG_FAKE_CONF="$FAKE_CONF" wg_status wg0)
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"public_key":"ifacePubKey=="' "$result_fake"
assert_contains "WG_FAKE_DUMP carga dump de archivo" '"peers":[{' "$result_fake"
echo "---"
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
@@ -3,11 +3,11 @@ name: write_mcp_jupyter_config
kind: function
lang: bash
domain: infra
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "write_mcp_jupyter_config([project_dir: string], [port: int]) -> string"
description: "Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server apuntando al venv local y puerto dado. Merge con jq si ya existe."
tags: [mcp, jupyter, config, setup, infra]
description: "Genera o actualiza .mcp.json con la config de jupyter-mcp-server apuntando al console-script del venv local (transport stdio + flags --jupyter-url/--jupyter-token). Merge con jq reemplazando la entrada jupyter entera."
tags: [mcp, jupyter, config, setup, infra, notebook]
uses_functions: []
uses_types: []
returns: []
@@ -30,10 +30,28 @@ file_path: "bash/functions/infra/write_mcp_jupyter_config.sh"
```bash
source write_mcp_jupyter_config.sh
path=$(write_mcp_jupyter_config /home/lucas/analysis/finanzas 8890)
path=$(write_mcp_jupyter_config $HOME/fn_registry/analysis/finanzas 8890)
echo "Config MCP en: $path"
# Genera .mcp.json con:
# "command": ".../.venv/bin/jupyter-mcp-server"
# "args": ["--transport","stdio","--jupyter-url","http://localhost:8890","--jupyter-token",""]
```
## Notas
## Cuando usarla
El MCP se invoca como modulo Python (`python -m jupyter_mcp_server`) usando el python del venv local, nunca una instalacion global. Si `.mcp.json` ya existe y jq esta disponible, hace merge conservando otros servidores MCP. Sin jq, sobrescribe el archivo.
- Al crear un analysis Jupyter nuevo (la usa el pipeline `init_jupyter_analysis`).
- Tras mover/recrear un venv y necesitar regenerar el `.mcp.json` del analysis.
- Para reparar un `.mcp.json` con el comando viejo roto (`python -m jupyter_mcp_server.server`).
## Gotchas
- **NUNCA `python -m jupyter_mcp_server.server`** — `server.py` no tiene bloque `__main__`; el proceso importa y sale 0 y el MCP nunca arranca. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`), expuesta como console-script `jupyter-mcp-server`. Sin subcomando arranca en stdio por defecto.
- **No usa env vars** `SERVER_URL`/`TOKEN`. La CLI lee flags `--jupyter-url` / `--jupyter-token` (cubren document + runtime). Configs viejas con bloque `env` quedan inertes.
- **Tolera Jupyter apagado al boot**: el MCP responde `initialize` tras un connect-timeout (~10s) y sirve igual. Arrancar Jupyter despues en `:port` y los tools se enganchan. No hace falta reiniciar Claude por tener Jupyter caido al inicio.
- **Requiere `jupyter-mcp-server` instalado en el venv**: `uv pip install jupyter-mcp-server`. La funcion aborta si el console-script no existe.
- **Path atado al venv del analysis**: si borras el analysis, ese `.mcp.json` apunta a un binario inexistente. Para un MCP jupyter global e independiente, el `.mcp.json` raiz de `fn_registry` usa el binario del venv canonico `python/.venv/bin/jupyter-mcp-server` (sobrevive el borrado de cualquier analysis).
- **Merge con jq usa `+` (shallow)** en el mapa de servidores para reemplazar la entrada `jupyter` entera; `*` (deep) dejaba keys huerfanas de configs viejas.
## Capability growth log
- v1.1.0 (2026-05-28) — fix comando roto: console-script `jupyter-mcp-server` + flags stdio en vez de `python -m ...server` + env vars. Merge `+` para reemplazar entrada entera. Tag `notebook`.
@@ -1,10 +1,18 @@
# write_mcp_jupyter_config
# -------------------------
# Genera o actualiza .mcp.json con la configuracion de jupyter-mcp-server.
# Usa el python del venv local con -m jupyter_mcp_server.server.
# Configura via env vars (SERVER_URL, TOKEN) — no CLI args.
# Usa el console-script `jupyter-mcp-server` del venv local con transport stdio
# y los flags --jupyter-url / --jupyter-token (NO env vars, NO `-m ...server`).
# Hace merge si ya existe .mcp.json (requiere jq).
#
# GOTCHA (2026-05-28): `python -m jupyter_mcp_server.server` NO arranca nada —
# server.py no tiene bloque __main__, asi que el proceso importa y sale 0 y el
# MCP nunca levanta. El entrypoint real es la CLI (`jupyter_mcp_server.CLI:server`,
# expuesta como console-script `jupyter-mcp-server`), que sin subcomando arranca
# en stdio por defecto. La config tampoco lee SERVER_URL/TOKEN: usa los flags
# --jupyter-url / --jupyter-token. El MCP tolera que Jupyter este apagado al
# arrancar (responde `initialize` tras un connect-timeout ~10s y sirve igual).
#
# USO (sourced):
# source write_mcp_jupyter_config.sh
# write_mcp_jupyter_config /path/to/project 8888
@@ -17,14 +25,15 @@ write_mcp_jupyter_config() {
abs_project="$(cd "$project_dir" && pwd)"
local python_bin="${abs_project}/.venv/bin/python"
local mcp_bin="${abs_project}/.venv/bin/jupyter-mcp-server"
if [ ! -f "$python_bin" ]; then
echo "write_mcp_jupyter_config: python no encontrado en ${python_bin}" >&2
return 1
fi
# Verificar que el modulo esta instalado
if ! "$python_bin" -c "import jupyter_mcp_server" 2>/dev/null; then
echo "write_mcp_jupyter_config: jupyter_mcp_server no instalado en el venv" >&2
# Verificar que el console-script esta instalado
if [ ! -x "$mcp_bin" ]; then
echo "write_mcp_jupyter_config: jupyter-mcp-server no instalado en el venv (${mcp_bin}). Instala con: uv pip install jupyter-mcp-server" >&2
return 1
fi
@@ -33,12 +42,12 @@ write_mcp_jupyter_config() {
{
"mcpServers": {
"jupyter": {
"command": "${python_bin}",
"args": ["-m", "jupyter_mcp_server.server"],
"env": {
"SERVER_URL": "http://localhost:${port}",
"TOKEN": ""
}
"command": "${mcp_bin}",
"args": [
"--transport", "stdio",
"--jupyter-url", "http://localhost:${port}",
"--jupyter-token", ""
]
}
}
}
@@ -46,8 +55,10 @@ EOF
)
if [ -f "$mcp_file" ] && command -v jq &>/dev/null; then
# Merge conservando otros servidores MCP
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) * (.[1].mcpServers // {}))}' \
# Merge conservando otros servidores MCP. Usa `+` (shallow) en el mapa de
# servidores para REEMPLAZAR la entrada `jupyter` entera — `*` (deep) dejaba
# keys huerfanas de configs viejas (ej. bloque `env` obsoleto).
jq -s '.[0] * {mcpServers: ((.[0].mcpServers // {}) + (.[1].mcpServers // {}))}' \
"$mcp_file" <(echo "$new_config") > "${mcp_file}.tmp"
mv "${mcp_file}.tmp" "$mcp_file"
else
+1 -1
View File
@@ -47,7 +47,7 @@ file_path: "bash/functions/pipelines/agent_scaffold.sh"
```bash
# Crear agente basico con openai
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
export FN_REGISTRY_ROOT=$HOME/fn_registry
bash bash/functions/pipelines/agent_scaffold.sh monitor-bot \
--display-name "Monitor Agent" \
--description "Monitorea servicios y reporta estado" \
+2 -2
View File
@@ -30,14 +30,14 @@ file_path: "bash/functions/pipelines/backup_all.sh"
```bash
# Backup manual a ~/backups/fn_registry
export FN_REGISTRY_ROOT=/home/lucas/fn_registry
export FN_REGISTRY_ROOT=$HOME/fn_registry
backup_all ~/backups/fn_registry
# Salida esperada:
# 2026-05-07T10:30:00+02:00 registry=4194304B ops=3 vaults=2 partial_errors=0 elapsed=12s
# Entrada en crontab (diario a las 02:00)
# 0 2 * * * FN_REGISTRY_ROOT=/home/lucas/fn_registry bash /home/lucas/fn_registry/bash/functions/pipelines/backup_all.sh ~/backups/fn_registry
# 0 2 * * * FN_REGISTRY_ROOT=$HOME/fn_registry bash $HOME/fn_registry/bash/functions/pipelines/backup_all.sh ~/backups/fn_registry
```
## Estructura de backup_root/
@@ -43,7 +43,7 @@ file_path: "bash/functions/pipelines/clone_project_subrepos.sh"
# analysis domain_coverage_gaps [cloned]
#
# Siguiente paso sugerido:
# cd /home/lucas/fn_registry && CGO_ENABLED=1 ./fn index && ./fn sync
# cd $HOME/fn_registry && CGO_ENABLED=1 ./fn index && ./fn sync
# Con owner alternativo
./fn run clone_project_subrepos aurgi --owner miorg
@@ -65,7 +65,7 @@ Cuando llegas a un PC nuevo con solo fn_registry clonado y quieres trabajar en u
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
- `GITEA_URL` — URL base de Gitea; default `https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com`
- Auth git/ssh: el pipeline confía en la config local del usuario (SSH key, credential helper)
@@ -38,7 +38,7 @@ clone_project_subrepos() {
fi
# --- Resolver paths ---
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
local db="$registry_root/registry.db"
local gitea_url="${GITEA_URL:-https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com}"
+2 -2
View File
@@ -31,7 +31,7 @@ output: "Compila el .exe y lo despliega al escritorio de Windows. Imprime progre
```bash
# Desde dentro del directorio de la app (sin arg)
cd /home/lucas/fn_registry/cpp/apps/chart_demo
cd $HOME/fn_registry/cpp/apps/chart_demo
fn run compile_cpp_app
# Con nombre explicito desde cualquier directorio
@@ -51,7 +51,7 @@ bash bash/functions/pipelines/compile_cpp_app.sh graph_explorer
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
- `BUILD_WIN` — directorio de build Windows; default `$FN_REGISTRY_ROOT/cpp/build/windows`
- `WIN_DESKTOP_APPS` — directorio destino; default `/mnt/c/Users/lucas/Desktop/apps`
+1 -1
View File
@@ -40,7 +40,7 @@ compile_cpp_app() {
deploy_cpp_exe_to_windows "$APP" "$APP_DIR"
# --- Resumen final ---
local root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
local root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
local build_win="${BUILD_WIN:-$root/cpp/build/windows}"
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
local final_exe="$win_desktop_apps/$APP/$APP.exe"
@@ -0,0 +1,65 @@
---
name: compile_wails_app
kind: pipeline
lang: bash
domain: pipelines
version: "0.1.0"
purity: impure
signature: "compile_wails_app(app_name_or_empty: string) -> void"
description: "Pipeline que resuelve la app Wails desde el nombre o CWD, la compila para Windows con wails build -platform windows/amd64 (detectando -tags goolm automaticamente si la app usa E2EE Matrix), y despliega el .exe al escritorio de Windows + relanza el proceso. Equivalente a compile_cpp_app pero para apps Wails (Go + WebView2)."
tags: [wails, windows, compile, pipelines, launch, matrix-mas]
uses_functions:
- resolve_cpp_app_dir_bash_infra
- deploy_wails_exe_to_windows_bash_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/compile_wails_app.sh"
params:
- name: app_name_or_empty
desc: "Nombre de la app Wails a compilar (opcional). Sin arg se deduce desde el directorio actual si estamos dentro de projects/*/apps/<X>/ o apps/<X>/. Lista apps disponibles si no puede deducirlo."
output: "Compila el .exe con wails build, lo despliega al escritorio de Windows y relanza el proceso. Imprime progreso por steps a stderr y resumen final con ls -lh del .exe resultante."
---
## Ejemplo
```bash
# Desde el directorio de la app (deduce nombre automaticamente)
cd projects/element_agents/apps/matrix_client_pc
./fn run compile_wails_app
# Desde la raiz del registry, con nombre explicito
cd $HOME/fn_registry
./fn run compile_wails_app matrix_admin_panel
# Directo sin fn run
bash bash/functions/pipelines/compile_wails_app.sh matrix_client_pc
```
## Cuando usarla
Usar cuando quieras rebuild + redeploy + relanzar una app Wails con un solo comando durante iteracion activa de desarrollo. Equivale al slash command `/compile` aplicado a targets Wails. El pipeline detecta automaticamente si la app necesita `-tags goolm` (apps Matrix con E2EE).
## Gotchas
- Requiere `wails` CLI instalado en PATH y mingw-w64 configurado para cross-compile (`GOARCH=amd64 GOOS=windows` via toolchain Wails).
- Si la app usa `-tags goolm` (E2EE Matrix), esta pipeline lo detecta automaticamente: busca `matrix_crypto_init` en `app.md` o `"build:tags": "goolm"` en `wails.json`. Si la deteccion falla, pasar la variable `TAGS` o editar el `wails.json`.
- El relanzar despues del deploy es la diferencia clave con `compile_cpp_app`: las apps Wails son single-binary (no DLLs adicionales) y arrancan en <1s, lo que hace iteracion muy rapida.
- Si el build falla con `no required module provides package`, ejecutar `go mod tidy` en el directorio de la app antes de volver a compilar.
- `matrix_client_pc` tiene helpers en `internal/infra/` que son copias vendored de `functions/infra/` del registry padre. Si actualizas un helper en el registry padre, debes copiarlo manualmente a la app antes de compilar — el build de Wails no ve el modulo padre.
- El deploy mata el proceso anterior con `taskkill.exe /F` (pre-autorizado) antes de copiar el .exe, para evitar "Permission denied" de Windows al sobreescribir un binario en uso.
- Variable de entorno `WIN_DESKTOP_APPS` controla el destino; default `/mnt/c/Users/lucas/Desktop/apps`.
## Flujo
1. `resolve_cpp_app_dir` — deduce nombre y directorio absoluto de la app (desde CWD o arg)
2. Verifica `wails.json` y `go.mod` en el directorio de la app
3. Detecta si necesita `-tags goolm` (app.md referencia `matrix_crypto_init` o wails.json lo declara)
4. `wails build -platform windows/amd64 [tags]` desde el directorio de la app
5. `deploy_wails_exe_to_windows` — mata proceso, copia .exe, relanza y verifica PID
6. Imprime `ls -lh` del exe final en `Desktop/apps/<APP>/`
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Pipeline: compile_wails_app — Resuelve la app Wails desde el nombre o CWD,
# la compila para Windows con wails build y despliega al escritorio + relanza.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/../infra"
source "$INFRA_DIR/resolve_cpp_app_dir.sh"
source "$INFRA_DIR/deploy_wails_exe_to_windows.sh"
compile_wails_app() {
local app_arg="${1:-}"
# --- Paso 1: Resolver nombre y directorio de la app ---
echo "[1/3] Resolviendo app..." >&2
local resolved
resolved=$(resolve_cpp_app_dir "$app_arg")
local APP APP_DIR
APP="$(echo "$resolved" | cut -f1)"
APP_DIR="$(echo "$resolved" | cut -f2)"
echo " App: $APP" >&2
echo " Dir: $APP_DIR" >&2
# --- Verificar que es una app Wails (no C++) ---
if [ ! -f "$APP_DIR/wails.json" ]; then
echo "ERROR: $APP_DIR/wails.json no encontrado." >&2
echo "La app '$APP' no es una app Wails." >&2
echo "Si es C++, usa compile_cpp_app en su lugar." >&2
return 1
fi
if [ ! -f "$APP_DIR/go.mod" ]; then
echo "ERROR: $APP_DIR/go.mod no encontrado." >&2
echo "Una app Wails requiere go.mod. Ejecuta 'go mod init' en $APP_DIR." >&2
return 1
fi
# --- Paso 2: Compilar para Windows con wails ---
echo "" >&2
echo "[2/3] Compilando '$APP' para Windows (wails + mingw)..." >&2
# Detectar si necesita -tags goolm:
# 1. app.md declara matrix_crypto_init en uses_functions (E2EE habilitado)
# 2. wails.json tiene "build:tags": "goolm" (o "buildTags": "goolm")
local TAGS=""
local app_md="${APP_DIR}/app.md"
local wails_json="${APP_DIR}/wails.json"
local needs_goolm=0
if [ -f "$app_md" ] && grep -q "matrix_crypto_init" "$app_md" 2>/dev/null; then
needs_goolm=1
echo " Detectado matrix_crypto_init en app.md -> usando -tags goolm" >&2
fi
if [ "$needs_goolm" -eq 0 ] && [ -f "$wails_json" ]; then
if grep -qE '"(build:tags|buildTags)"\s*:\s*"goolm"' "$wails_json" 2>/dev/null; then
needs_goolm=1
echo " Detectado goolm en wails.json -> usando -tags goolm" >&2
fi
fi
if [ "$needs_goolm" -eq 1 ]; then
TAGS="-tags goolm"
fi
(
cd "$APP_DIR"
# shellcheck disable=SC2086
wails build -platform windows/amd64 $TAGS
)
# --- Paso 3: Desplegar al escritorio + relanzar ---
echo "" >&2
echo "[3/3] Desplegando '$APP' al escritorio + relanzar..." >&2
deploy_wails_exe_to_windows "$APP" "$APP_DIR"
# --- Resumen final ---
local win_desktop_apps="${WIN_DESKTOP_APPS:-/mnt/c/Users/lucas/Desktop/apps}"
local final_exe="$win_desktop_apps/$APP/$APP.exe"
echo "" >&2
if [ -f "$final_exe" ]; then
echo "===== compile_wails_app: OK =====" >&2
ls -lh "$final_exe" >&2
else
echo "WARN: no se encuentra $final_exe" >&2
fi
}
compile_wails_app "${1:-}"
+1 -1
View File
@@ -63,7 +63,7 @@ file_path: "bash/functions/pipelines/dockerize_app.sh"
```bash
# Deploy completo con basicAuth
cd /home/lucas/fn_registry
cd $HOME/fn_registry
bash bash/functions/pipelines/dockerize_app.sh kanban \
--domain kanban.organic-machine.com \
--port 8421 \
+1 -1
View File
@@ -46,7 +46,7 @@ bash bash/functions/pipelines/full_git_pull.sh
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
- `FN_REGISTRY_API`, `REGISTRY_API_TOKEN` — se cargan de `pass registry/*`
## Notas
+3 -2
View File
@@ -12,8 +12,9 @@ source "$INFRA_DIR/git_pull_with_stash.sh"
source "$INFRA_DIR/pass_get.sh"
full_git_pull() {
# Resolver raiz del registry
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
# Resolver raiz del registry. Deriva de SCRIPT_DIR (bash/functions/pipelines/)
# para funcionar en cualquier PC sin path hardcodeado.
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
cd "$registry_root"
echo "=== full_git_pull: inicio ===" >&2
+1 -1
View File
@@ -55,7 +55,7 @@ bash bash/functions/pipelines/full_git_push.sh "feat: nueva funcion"
## Variables de entorno
- `FN_REGISTRY_ROOT` — raiz del registry; default `/home/lucas/fn_registry`
- `FN_REGISTRY_ROOT` — raiz del registry; default `$HOME/fn_registry`
- `GITEA_URL`, `GITEA_TOKEN` — se cargan de `pass agentes/gitea-url` y `pass gitea/dataforge-git-token`
- `FN_REGISTRY_API`, `REGISTRY_API_TOKEN` — se cargan de `pass registry/*`
+31 -4
View File
@@ -18,8 +18,9 @@ source "$CYBERSEC_DIR/scan_secrets_in_dirty.sh"
full_git_push() {
local commit_message="${1:-}"
# Resolver raiz del registry
local registry_root="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
# Resolver raiz del registry. Deriva de SCRIPT_DIR (bash/functions/pipelines/)
# para funcionar en cualquier PC sin path hardcodeado.
local registry_root="${FN_REGISTRY_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
cd "$registry_root"
echo "=== full_git_push: inicio ===" >&2
@@ -52,8 +53,15 @@ full_git_push() {
[[ -z "$dir_path" ]] && continue
local d="$registry_root/$dir_path"
[[ -d "$d" ]] || continue
[[ -d "$d/.git" ]] && continue
echo " auto-init: $d" >&2
# Skip solo si ya tiene .git CON remote origin. Un .git sin origin
# (init local que nunca llego a crear repo Gitea) cae a push step y
# falla con "'origin' does not appear to be a git repository".
if [[ -d "$d/.git" ]]; then
git -C "$d" remote get-url origin >/dev/null 2>&1 && continue
echo " fix-remote: $d (.git sin origin)" >&2
else
echo " auto-init: $d" >&2
fi
ensure_repo_synced "$d" dataforge "$(basename "$d")" master "chore: initial sync" || \
echo " [warn] fallo inicializando $d" >&2
done < <(sqlite3 "$registry_root/registry.db" "SELECT dir_path FROM apps WHERE dir_path != '' UNION SELECT dir_path FROM analysis WHERE dir_path != '';" 2>/dev/null)
@@ -67,6 +75,25 @@ full_git_push() {
# Redescubrir repos tras posibles inicializaciones
repos=$(discover_git_repos "$registry_root")
# --- Paso 1c: Incluir el repo de configuracion de Claude ---
# Los archivos de ~/.claude/ (settings.json, commands, skills, CLAUDE.md...)
# son symlinks a un repo git externo (dataforge/repo_Claude). Lo resolvemos
# de forma portable siguiendo el symlink de settings.json — sin hardcodear
# el path, que difiere entre PCs. Si resuelve a un repo git, lo anadimos a
# la lista para que pase por scan-secrets + auto-commit + push como los demas.
local claude_repo=""
if [[ -L "$HOME/.claude/settings.json" ]]; then
local _claude_settings_real
_claude_settings_real=$(readlink -f "$HOME/.claude/settings.json" 2>/dev/null || true)
if [[ -n "$_claude_settings_real" ]]; then
claude_repo=$(git -C "$(dirname "$_claude_settings_real")" rev-parse --show-toplevel 2>/dev/null || true)
fi
fi
if [[ -n "$claude_repo" && -d "$claude_repo/.git" ]]; then
echo "[1c] Incluyendo repo de config Claude: $claude_repo" >&2
repos="$repos"$'\n'"$claude_repo"
fi
# --- Paso 2: Escanear secrets ---
echo "" >&2
echo "[2/6] Escaneando secrets en dirty trees..." >&2
@@ -34,11 +34,11 @@ file_path: "bash/functions/pipelines/generate_capability_doc.sh"
```bash
# Regenerar tabla de notebook (ya existe, preserva Ejemplo canonico / Fronteras)
./bash/functions/pipelines/generate_capability_doc.sh notebook
# → /home/lucas/fn_registry/docs/capabilities/notebook.md updated (5 functions)
# → $HOME/fn_registry/docs/capabilities/notebook.md updated (5 functions)
# Crear pagina nueva para un grupo sin pagina todavia
./bash/functions/pipelines/generate_capability_doc.sh metabase
# → /home/lucas/fn_registry/docs/capabilities/metabase.md created (12 functions)
# → $HOME/fn_registry/docs/capabilities/metabase.md created (12 functions)
# Especificar registry y destino custom
./bash/functions/pipelines/generate_capability_doc.sh android \
@@ -49,7 +49,7 @@ file_path: "bash/functions/pipelines/generate_capability_doc.sh"
# Grupo sin funciones todavia (avisa pero no falla)
./bash/functions/pipelines/generate_capability_doc.sh nuevo_grupo
# WARN: El grupo 'nuevo_grupo' no tiene funciones con ese tag en registry.db.
# → /home/lucas/fn_registry/docs/capabilities/nuevo_grupo.md created (0 functions)
# → $HOME/fn_registry/docs/capabilities/nuevo_grupo.md created (0 functions)
```
## Comportamiento detallado
+1 -1
View File
@@ -26,7 +26,7 @@
set -euo pipefail
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-/home/lucas/fn_registry}"
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}"
# shellcheck disable=SC1091
source "$REGISTRY_ROOT/bash/functions/infra/keepass_dump.sh"
@@ -27,7 +27,7 @@ params:
- name: app_name
desc: "Nombre de la app C++ (ej: chart_demo, registry_dashboard). Se usa para localizar el .exe en cpp/build/windows/apps/<app>/ y el destino Desktop/apps/<app>/."
- name: app_dir
desc: "Ruta absoluta al directorio fuente de la app (ej: /home/lucas/fn_registry/cpp/apps/chart_demo). Requerido para localizar enrichers/, runtime/ y app.md."
desc: "Ruta absoluta al directorio fuente de la app (ej: $HOME/fn_registry/cpp/apps/chart_demo). Requerido para localizar enrichers/, runtime/ y app.md."
- name: "--build"
desc: "Flag opcional. Si presente, compila la app para Windows antes del deploy. Por defecto off (asume .exe ya compilado)."
output: "Imprime 'OK: <app_name> redeployed (build=yes/no, PID=N)' en stdout. Exit 1 en cualquier paso fallido con mensaje de error indicando el paso."
@@ -37,10 +37,10 @@ output: "Imprime 'OK: <app_name> redeployed (build=yes/no, PID=N)' en stdout. Ex
```bash
# Solo redeploy (asume build ya hecho)
redeploy_cpp_app_windows "registry_dashboard" "/home/lucas/fn_registry/projects/fn_monitoring/apps/registry_dashboard"
redeploy_cpp_app_windows "registry_dashboard" "$HOME/fn_registry/projects/fn_monitoring/apps/registry_dashboard"
# Con build previo
redeploy_cpp_app_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo" --build
redeploy_cpp_app_windows "chart_demo" "$HOME/fn_registry/cpp/apps/chart_demo" --build
```
## Comportamiento
@@ -20,7 +20,7 @@ error_type: "error_go_core"
imports: []
params:
- name: registry_db_path
desc: "ruta a registry.db local (default: /home/lucas/fn_registry/registry.db)"
desc: "ruta a registry.db local (default: $HOME/fn_registry/registry.db)"
- name: container_name
desc: "nombre del contenedor Metabase (default: metabase)"
- name: dest_path
@@ -40,7 +40,7 @@ file_path: "bash/functions/pipelines/setup_metabase_volume.sh"
# Con argumentos explícitos
./functions/pipelines/setup_metabase_volume.sh \
/home/lucas/fn_registry/registry.db \
$HOME/fn_registry/registry.db \
metabase \
/registry.db
```
@@ -59,7 +59,7 @@ El pipeline usa `set -euo pipefail` — cualquier fallo en una función individu
Las funciones individuales se sourcean desde sus rutas en el registry, relativas a `REGISTRY_ROOT` detectado automáticamente desde la ubicación del script.
Defaults:
- `REGISTRY_DB_PATH`: `/home/lucas/fn_registry/registry.db`
- `REGISTRY_DB_PATH`: `$HOME/fn_registry/registry.db`
- `CONTAINER_NAME`: `metabase`
- `DEST_PATH`: `/registry.db`
@@ -67,7 +67,7 @@ Nota de persistencia: `docker cp` copia al contenedor en ejecución. Si el conte
```yaml
volumes:
- /home/lucas/fn_registry:/fn_registry:ro
- $HOME/fn_registry:/fn_registry:ro
```
Y usar `--registry-db-path /fn_registry/registry.db`.
@@ -10,7 +10,7 @@
#
# ARGUMENTOS (opcionales, con defaults):
# REGISTRY_DB_PATH Ruta local al registry.db
# Default: /home/lucas/fn_registry/registry.db
# Default: <raiz_del_registry>/registry.db
# CONTAINER_NAME Nombre del contenedor Docker de Metabase
# Default: metabase
# DEST_PATH Ruta destino dentro del contenedor
@@ -26,7 +26,7 @@ source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
source "$REGISTRY_ROOT/bash/functions/infra/assert_docker_container_running.sh"
source "$REGISTRY_ROOT/bash/functions/infra/docker_cp_file.sh"
REGISTRY_DB_PATH="${1:-/home/lucas/fn_registry/registry.db}"
REGISTRY_DB_PATH="${1:-$REGISTRY_ROOT/registry.db}"
CONTAINER_NAME="${2:-metabase}"
DEST_PATH="${3:-/registry.db}"
+2 -2
View File
@@ -44,11 +44,11 @@ file_path: "bash/functions/pipelines/vault_audit.sh"
```bash
# Auditar un vault especifico
FN_REGISTRY_ROOT=/home/lucas/fn_registry \
FN_REGISTRY_ROOT=$HOME/fn_registry \
bash bash/functions/pipelines/vault_audit.sh turismo_spain
# Auditar todos los vaults
FN_REGISTRY_ROOT=/home/lucas/fn_registry \
FN_REGISTRY_ROOT=$HOME/fn_registry \
bash bash/functions/pipelines/vault_audit.sh --all
# Solo layout + index + aggregate (sin profilers, mas rapido)
+1 -1
View File
@@ -29,7 +29,7 @@ file_path: "bash/functions/shell/assert_file_exists.sh"
```bash
source functions/shell/assert_file_exists.sh
size=$(assert_file_exists /home/lucas/fn_registry/registry.db)
size=$(assert_file_exists $HOME/fn_registry/registry.db)
echo "Tamaño: $size bytes"
```
@@ -32,7 +32,7 @@ file_path: "bash/functions/shell/validate_registry_paths.sh"
```bash
source validate_registry_paths.sh
validate_registry_paths /home/lucas/fn_registry/registry.db functions /home/lucas/fn_registry
validate_registry_paths $HOME/fn_registry/registry.db functions $HOME/fn_registry
# Output (TSV):
# cdp_click_go_browser functions/infra/cdp_click.go browser functions
+24
View File
@@ -535,3 +535,27 @@ set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/ag
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
endif()
# --- kanban_cpp (lives in apps/, issue 0096) ---
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
endif()
# --- data_table_bench (lives in apps/, issue 0133) ---
# Requires SQLite3 dev libs. Skip silently when not available (e.g. cross-windows build).
set(_DATA_TABLE_BENCH_DIR ${CMAKE_SOURCE_DIR}/../apps/data_table_bench)
if(EXISTS ${_DATA_TABLE_BENCH_DIR}/CMakeLists.txt)
find_package(SQLite3 QUIET)
if(SQLite3_FOUND)
add_subdirectory(${_DATA_TABLE_BENCH_DIR} ${CMAKE_BINARY_DIR}/apps/data_table_bench)
else()
message(STATUS "Skipping data_table_bench (SQLite3 dev libs not found)")
endif()
endif()
# --- image_to_3d_studio (lives in projects/imagegen/apps/) ---
set(_IMAGE_TO_3D_STUDIO_DIR ${CMAKE_SOURCE_DIR}/../projects/imagegen/apps/image_to_3d_studio)
if(EXISTS ${_IMAGE_TO_3D_STUDIO_DIR}/CMakeLists.txt)
add_subdirectory(${_IMAGE_TO_3D_STUDIO_DIR} ${CMAKE_BINARY_DIR}/apps/image_to_3d_studio)
endif()
+250
View File
@@ -0,0 +1,250 @@
#include "core/ansi_parser.h"
namespace fn_term {
// Paleta xterm-16 en ABGR (little-endian: R,G,B,A en memoria = RGBA8888 en lectura).
// Index 0-7 colores normales, 8-15 brillantes, 16 = default.
const uint32_t kPalette16[17] = {
0xFF000000, // 0 black
0xFF0000AA, // 1 red
0xFF00AA00, // 2 green
0xFF00AAAA, // 3 yellow (dark)
0xFFAA0000, // 4 blue
0xFFAA00AA, // 5 magenta
0xFFAAAA00, // 6 cyan
0xFFAAAAAA, // 7 white (light grey)
0xFF555555, // 8 bright black (dark grey)
0xFF5555FF, // 9 bright red
0xFF55FF55, // 10 bright green
0xFF55FFFF, // 11 bright yellow
0xFFFF5555, // 12 bright blue
0xFFFF55FF, // 13 bright magenta
0xFFFFFF55, // 14 bright cyan
0xFFFFFFFF, // 15 bright white
0xFFCCCCCC, // 16 default (light grey)
};
AnsiParser::AnsiParser() {
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
}
void AnsiParser::reset() {
state_ = State::Ground;
cur_fg_ = kColorDefault;
cur_bg_ = kColorDefault;
cur_bold_ = 0;
param_count_ = 0;
cur_param_ = 0;
for (int i = 0; i < kMaxParams; i++) params_[i] = 0;
}
void AnsiParser::feed(const char* data, size_t n,
const std::function<void(const AnsiEvent&)>& cb) {
for (size_t i = 0; i < n; i++) {
process_byte(static_cast<unsigned char>(data[i]), cb);
}
}
void AnsiParser::flush_param() {
if (param_count_ < kMaxParams) {
params_[param_count_++] = cur_param_;
}
cur_param_ = 0;
}
void AnsiParser::apply_sgr(const std::function<void(const AnsiEvent&)>& /*cb*/) {
// Si no hay params → reset (SGR 0).
int n = (param_count_ == 0) ? 1 : param_count_;
const int* p = (param_count_ == 0) ? nullptr : params_;
for (int i = 0; i < n; i++) {
int code = (p ? p[i] : 0);
if (code == 0) {
// Reset todo
cur_fg_ = kColorDefault;
cur_bg_ = kColorDefault;
cur_bold_ = 0;
} else if (code == 1) {
cur_bold_ = 1;
} else if (code == 22) {
cur_bold_ = 0;
} else if (code >= 30 && code <= 37) {
cur_fg_ = static_cast<uint8_t>(code - 30);
} else if (code == 39) {
cur_fg_ = kColorDefault;
} else if (code >= 40 && code <= 47) {
cur_bg_ = static_cast<uint8_t>(code - 40);
} else if (code == 49) {
cur_bg_ = kColorDefault;
} else if (code >= 90 && code <= 97) {
cur_fg_ = static_cast<uint8_t>(code - 90 + 8);
} else if (code >= 100 && code <= 107) {
cur_bg_ = static_cast<uint8_t>(code - 100 + 8);
}
// Otros códigos ignorados silenciosamente (v1 anti-scope).
}
}
void AnsiParser::dispatch_csi(unsigned char final_byte,
const std::function<void(const AnsiEvent&)>& cb) {
AnsiEvent ev;
int p0 = (param_count_ > 0) ? params_[0] : 0;
int p1 = (param_count_ > 1) ? params_[1] : 0;
switch (final_byte) {
case 'H': case 'f': {
// CUP: ESC [ row ; col H (1-based → convertir a 0-based)
ev.type = AnsiEventType::CursorAbsolute;
ev.cursor_abs.row = (p0 > 0 ? p0 - 1 : 0);
ev.cursor_abs.col = (p1 > 0 ? p1 - 1 : 0);
cb(ev);
break;
}
case 'A': {
ev.type = AnsiEventType::CursorMove;
ev.cursor_rel.dir = CursorDir::Up;
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
cb(ev);
break;
}
case 'B': {
ev.type = AnsiEventType::CursorMove;
ev.cursor_rel.dir = CursorDir::Down;
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
cb(ev);
break;
}
case 'C': {
ev.type = AnsiEventType::CursorMove;
ev.cursor_rel.dir = CursorDir::Forward;
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
cb(ev);
break;
}
case 'D': {
ev.type = AnsiEventType::CursorMove;
ev.cursor_rel.dir = CursorDir::Back;
ev.cursor_rel.n = (p0 > 0 ? p0 : 1);
cb(ev);
break;
}
case 'J': {
// ED: erase in display. Solo param=2 (clear screen) soportado en v1.
if (p0 == 2 || p0 == 0) {
ev.type = AnsiEventType::EraseDisplay;
cb(ev);
}
break;
}
case 'K': {
// EL: erase in line. Solo param=2 (clear entire line) soportado en v1.
if (p0 == 2 || p0 == 0) {
ev.type = AnsiEventType::EraseLine;
cb(ev);
}
break;
}
case 'm': {
// SGR: select graphic rendition.
apply_sgr(cb);
break;
}
default:
// Secuencia CSI desconocida — ignorar silenciosamente.
break;
}
}
void AnsiParser::process_byte(unsigned char c,
const std::function<void(const AnsiEvent&)>& cb) {
switch (state_) {
case State::Ground:
if (c == 0x1B) {
state_ = State::Escape;
} else if (c == '\r') {
AnsiEvent ev; ev.type = AnsiEventType::CarriageReturn; cb(ev);
} else if (c == '\n') {
AnsiEvent ev; ev.type = AnsiEventType::Newline; cb(ev);
} else if (c == '\x08') {
AnsiEvent ev; ev.type = AnsiEventType::Backspace; cb(ev);
} else if (c >= 0x20 && c < 0x7F) {
// ASCII imprimible.
AnsiEvent ev;
ev.type = AnsiEventType::Char;
ev.cell.ch = static_cast<char32_t>(c);
ev.cell.fg = cur_fg_;
ev.cell.bg = cur_bg_;
ev.cell.bold = cur_bold_;
cb(ev);
} else if (c >= 0xC0) {
// Inicio de secuencia UTF-8 multi-byte.
// En v1 mapeamos todo >= 0x80 a '?' para evitar complejidad Unicode.
// TODO(0132): soporte Unicode completo en v2.
AnsiEvent ev;
ev.type = AnsiEventType::Char;
ev.cell.ch = U'?';
ev.cell.fg = cur_fg_;
ev.cell.bg = cur_bg_;
ev.cell.bold = cur_bold_;
cb(ev);
} else if (c >= 0x80 && c < 0xC0) {
// Continuation byte de UTF-8 → ignorar (fragmento de multi-byte).
}
// Otros control bytes (0x00-0x1F excl \r\n\x08\x1B) → ignorar.
break;
case State::Escape:
if (c == '[') {
state_ = State::CsiEntry;
param_count_ = 0;
cur_param_ = 0;
} else {
// Secuencia ESC desconocida (no-CSI) → volver a Ground.
state_ = State::Ground;
}
break;
case State::CsiEntry:
// Primer byte del CSI: puede ser un dígito, ';' o el final byte.
if (c >= '0' && c <= '9') {
cur_param_ = c - '0';
state_ = State::CsiParam;
} else if (c == ';') {
// Parámetro vacío → valor 0.
flush_param();
cur_param_ = 0;
state_ = State::CsiParam;
} else if (c >= 0x40 && c <= 0x7E) {
// Byte final inmediato sin parámetros.
dispatch_csi(c, cb);
state_ = State::Ground;
} else if (c == '?') {
// Modos privados (e.g. ESC[?25l cursor hide) → ignorar hasta final byte.
// Permanecemos en CsiEntry esperando el final byte.
} else {
// Byte inesperado → abortar CSI.
state_ = State::Ground;
}
break;
case State::CsiParam:
if (c >= '0' && c <= '9') {
cur_param_ = cur_param_ * 10 + (c - '0');
} else if (c == ';') {
flush_param();
cur_param_ = 0;
} else if (c >= 0x40 && c <= 0x7E) {
// Byte final: flush último param y despachar.
flush_param();
dispatch_csi(c, cb);
state_ = State::Ground;
} else {
// Byte inesperado → abortar.
state_ = State::Ground;
}
break;
}
}
} // namespace fn_term
+131
View File
@@ -0,0 +1,131 @@
#pragma once
// ansi_parser — parser ANSI/VT100 minimo, byte-a-byte, sin heap allocs por evento.
//
// Soporta:
// SGR: colores FG/BG 16 colores (30-37, 40-47, 90-97, 100-107), bold (1), reset (0).
// CUP (H): cursor absolute position row,col.
// CUU (A), CUD (B), CUF (C), CUB (D): cursor relative moves.
// ED (J): erase in display (param=2 → clear screen).
// EL (K): erase in line (param=2 → clear line).
// Carriage Return (\r), Newline (\n), Backspace (\x08).
// Text: caracteres imprimibles (excl. control bytes).
//
// No soportado (v1, anti-scope):
// 256/24-bit color, italics, underline, Unicode wide, OSC, DCS, SOS, PM, APC,
// CSI sequences > 16 parametros, character sets (SI/SO), private modes.
//
// Uso:
// fn_term::AnsiParser p;
// p.feed(data, n, [](const fn_term::AnsiEvent& ev) { /* handle */ });
//
// Thread-safety: NO. Cada instancia debe usarse desde un solo hilo.
#include <cstddef>
#include <cstdint>
#include <functional>
namespace fn_term {
// Codigos de color ANSI → index 0-15 en paleta CGA/xterm-16.
// 0-7: colores normales (black, red, green, yellow, blue, magenta, cyan, white)
// 8-15: colores brillantes (idem + bright)
// 16: color por defecto (FG o BG)
static constexpr uint8_t kColorDefault = 16;
// Paleta xterm-16 en RGBA8888 (A=0xFF), misma que la mayoria de terminales.
// Acceso: kPalette16[index], index in [0,15].
extern const uint32_t kPalette16[17]; // [16] = color "default" (blanco/negro)
// Una celda del terminal virtual.
struct AnsiCell {
char32_t ch = U' '; // codepoint Unicode (solo BMP en v1)
uint8_t fg = kColorDefault; // indice paleta 0-16 (16 = default)
uint8_t bg = kColorDefault;
uint8_t bold = 0;
uint8_t _pad = 0;
};
// Tipos de evento emitidos por el parser.
enum class AnsiEventType : uint8_t {
Char, // un caracter imprimible (AnsiEvent.cell.ch valido)
CursorMove, // AnsiEvent.row / .col delta o absoluto segun subtype
CursorAbsolute, // CUP: posicion absoluta 0-based (row, col)
EraseDisplay, // ED(2): limpiar pantalla completa
EraseLine, // EL(2): limpiar linea actual completa
CarriageReturn, // \r
Newline, // \n
Backspace, // \x08
};
// Subtipos de CursorMove.
enum class CursorDir : uint8_t { Up, Down, Forward, Back };
struct AnsiEvent {
AnsiEventType type;
union {
AnsiCell cell; // type == Char
struct {
CursorDir dir;
int n; // pasos (>= 1)
} cursor_rel; // type == CursorMove
struct {
int row; // 0-based
int col; // 0-based
} cursor_abs; // type == CursorAbsolute
// EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace: sin datos extra.
};
AnsiEvent() : type(AnsiEventType::Char), cell{} {}
};
// Clase principal. Stateful — mantiene el estado del parser entre llamadas a feed().
class AnsiParser {
public:
AnsiParser();
~AnsiParser() = default;
AnsiParser(const AnsiParser&) = delete;
AnsiParser& operator=(const AnsiParser&) = delete;
// Procesa `n` bytes de `data`. Emite eventos via `cb` en orden.
// cb puede ser llamada 0 o más veces por feed().
// Sin alloc heap por byte ni por evento.
void feed(const char* data, size_t n,
const std::function<void(const AnsiEvent&)>& cb);
// Resetea el estado del parser (útil al limpiar pantalla).
void reset();
// Atributos SGR actuales (se actualizan al procesar secuencias SGR).
uint8_t current_fg() const { return cur_fg_; }
uint8_t current_bg() const { return cur_bg_; }
uint8_t current_bold() const { return cur_bold_; }
private:
enum class State : uint8_t {
Ground, // estado normal: procesar texto
Escape, // recibido ESC
CsiEntry, // recibido ESC [
CsiParam, // acumulando parametros CSI
};
State state_ = State::Ground;
uint8_t cur_fg_ = kColorDefault;
uint8_t cur_bg_ = kColorDefault;
uint8_t cur_bold_ = 0;
// Buffer de parametros CSI (max 16 params de 4 digitos cada uno).
static constexpr int kMaxParams = 16;
int params_[kMaxParams];
int param_count_ = 0;
int cur_param_ = 0; // valor del param que se esta acumulando
void process_byte(unsigned char c,
const std::function<void(const AnsiEvent&)>& cb);
void flush_param();
void dispatch_csi(unsigned char final_byte,
const std::function<void(const AnsiEvent&)>& cb);
void apply_sgr(const std::function<void(const AnsiEvent&)>& cb);
};
} // namespace fn_term
+97
View File
@@ -0,0 +1,97 @@
---
name: ansi_parser
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "class fn_term::AnsiParser { void feed(const char* data, size_t n, const std::function<void(const fn_term::AnsiEvent&)>& cb); void reset(); uint8_t current_fg() const; uint8_t current_bg() const; uint8_t current_bold() const; }"
description: "Parser ANSI/VT100 minimo byte-a-byte sin alloc heap por evento. Soporta SGR colores FG/BG 16-color + bold + reset, cursor moves (CUP/CUU/CUD/CUF/CUB), erase display/line (ED 2, EL 2), CR/LF/BS. Statemachine simple con 4 estados. Emite AnsiEvent via callback."
tags: [ansi, vt100, terminal, parser, pure, state-machine, cpp-dashboard-viz]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [cstddef, cstdint, functional]
tested: true
tests:
- "SGR reset sets default colors"
- "SGR fg color 31 sets red"
- "SGR bg color 44 sets blue background"
- "SGR bright fg 91 sets bright red"
- "SGR bold sets bold flag"
- "cursor CUU moves up N"
- "cursor CUF moves forward N"
- "cursor CUP absolute position"
- "erase display ED 2"
- "erase line EL 2"
- "mixed text and SGR sequence"
- "newline and carriage return"
test_file_path: "cpp/tests/test_ansi_parser.cpp"
file_path: "cpp/functions/core/ansi_parser.cpp"
framework: ""
params:
- name: data
desc: "Puntero al buffer de bytes a procesar (output crudo de PTY/ConPTY)"
- name: n
desc: "Numero de bytes en data"
- name: cb
desc: "Callback invocado por cada evento emitido. Sin alloc — el AnsiEvent vive en el stack del parser"
output: "Sin retorno directo. Eventos emitidos via callback: AnsiEventType::Char (caracter + atributos SGR actuales), CursorMove (relativo), CursorAbsolute (CUP), EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace"
notes: "Usado por terminal_panel_cpp_viz como paso de parseo del output PTY. Anti-scope v1: sin 256/24-bit color, sin italics/underline, sin Unicode wide, sin OSC/DCS. UTF-8 multi-byte se mapea a '?' en v1."
---
# ansi_parser
Parser ANSI/VT100 minimo para el modulo `terminal_panel`. Sin heap allocs por byte procesado — la maquina de estados vive en el objeto y los `AnsiEvent` se emiten por callback en el stack del caller.
## Ejemplo
```cpp
#include "core/ansi_parser.h"
fn_term::AnsiParser parser;
std::string output;
// Procesar output crudo de PTY:
parser.feed(pty_buf, bytes_read, [&](const fn_term::AnsiEvent& ev) {
if (ev.type == fn_term::AnsiEventType::Char) {
// ev.cell.ch = codepoint, ev.cell.fg = color index 0-16
output += static_cast<char>(ev.cell.ch);
} else if (ev.type == fn_term::AnsiEventType::Newline) {
output += '\n';
}
});
```
## Cuando usarla
Cuando procesas output crudo de un PTY (Linux forkpty) o ConPTY (Windows) y necesitas extraer texto + atributos de color para renderizar en ImGui con `PushStyleColor`. Es la capa de parseo de `terminal_panel`.
## Secuencias soportadas (v1)
| Tipo | Secuencia | AnsiEventType |
|------|-----------|---------------|
| Texto ASCII | bytes 0x20-0x7E | Char |
| CR | `\r` (0x0D) | CarriageReturn |
| LF | `\n` (0x0A) | Newline |
| BS | `\x08` | Backspace |
| SGR reset | `ESC[0m` o `ESC[m` | (actualiza estado interno) |
| SGR bold | `ESC[1m` | (actualiza estado interno) |
| SGR FG 16 | `ESC[30-37m`, `ESC[90-97m` | (actualiza estado interno) |
| SGR BG 16 | `ESC[40-47m`, `ESC[100-107m` | (actualiza estado interno) |
| Cursor UP | `ESC[nA` | CursorMove (Up, n) |
| Cursor DOWN | `ESC[nB` | CursorMove (Down, n) |
| Cursor FWD | `ESC[nC` | CursorMove (Forward, n) |
| Cursor BACK | `ESC[nD` | CursorMove (Back, n) |
| CUP | `ESC[r;cH` | CursorAbsolute (0-based) |
| ED(2) | `ESC[2J` | EraseDisplay |
| EL(2) | `ESC[2K` | EraseLine |
## Gotchas
- Anti-scope v1: no 256-color (`ESC[38;5;Nm`), no 24-bit color, no italics/underline, no curses pesados.
- UTF-8 multi-byte: bytes de continuacion 0x80-0xBF ignorados; inicio 0xC0+ emite `?`. Soporte completo en v2.
- No thread-safe: cada instancia debe usarse desde un solo hilo (el reader thread del PTY).
- `kPalette16[16]` es el color "default" (gris claro). El caller decide si usar el color del tema o la paleta fija.
+60
View File
@@ -8,6 +8,8 @@
#include "compute_column_stats.h"
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
@@ -353,6 +355,59 @@ struct VizPanel {
mutable ViewMode last_non_table = ViewMode::Bar;
};
// ----------------------------------------------------------------------------
// StringPool — interning de strings para columnas de texto (issue 0133).
// Una instancia por State (NOT global) para aislar tablas independientes.
//
// intern(sv) devuelve un indice uint32_t estable para la vida del rebuild.
// El pool se limpia (clear()) al inicio de cada rebuild de snapshot columnar.
//
// Invariante de invalidacion de string_view:
// - El vector `strings` se reserva con reserve() ANTES del primer intern()
// para evitar reallocs que invalidarian los string_view del mapa.
// Si la estimacion es insuficiente (columna con mas unicos de lo esperado),
// el mapa se reconstruye post-push_back: intern() verifica cap antes de
// insertar en el map para cubrir este caso.
// ----------------------------------------------------------------------------
struct StringPool {
std::vector<std::string> strings; // strings unicos, por indice
std::unordered_map<std::string_view, uint32_t> index; // sv→id (sv apunta a strings[i])
void clear() {
strings.clear();
index.clear();
}
// intern: inserta si no existe. Devuelve indice estable.
// INVARIANTE: reserve() ANTES del primer intern() por columna para evitar
// reallocs que invalidarian los string_view del mapa. Si la estimacion fue
// insuficiente, forzamos reserve(size+1) ANTES de emplace_back para que
// la realloc ocurra antes de que cualquier sv del mapa apunte al buffer
// viejo — y reconstruimos el mapa desde cero tras la realloc.
uint32_t intern(std::string_view sv) {
auto it = index.find(sv);
if (it != index.end()) return it->second;
uint32_t id = (uint32_t)strings.size();
if (strings.size() == strings.capacity()) {
// Realloc inminente: hacerlo ANTES de insertar en index para que
// los string_view existentes no queden dangling. Tras el reserve,
// reconstruimos el index desde cero porque los punteros cambiaron.
strings.reserve(strings.capacity() == 0 ? 64 : strings.capacity() * 2);
index.clear();
for (uint32_t i = 0; i < (uint32_t)strings.size(); ++i)
index.emplace(std::string_view(strings[i]), i);
}
strings.emplace_back(sv);
// string_view apunta al almacenamiento interno (strings[id]), estable
// porque acabamos de garantizar capacidad suficiente.
index.emplace(std::string_view(strings[id]), id);
return id;
}
const std::string& at(uint32_t id) const { return strings[id]; }
bool empty() const { return strings.empty(); }
};
// ----------------------------------------------------------------------------
// State: stage pipeline + viz globales.
// ----------------------------------------------------------------------------
@@ -419,6 +474,11 @@ struct State {
std::vector<DrillStep> drill_back;
std::vector<DrillStep> drill_forward;
// String interning pool (issue 0133, Change 2).
// Limpiado y repoblado en cada rebuild del snapshot columnar.
// NOT global — una instancia por State para aislar tablas independientes.
StringPool string_pool;
// Helpers (definidos en compute_stage.cpp).
Stage& raw();
const Stage& raw() const;
+15 -2
View File
@@ -269,8 +269,21 @@ Response request(const Request& req) {
}
cmd << ' ' << sh_q(req.url)
<< " -o " << sh_q(tmp_body_out)
<< " 2>&1";
<< " -o " << sh_q(tmp_body_out);
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
// extra positional arg to curl, which treats it as a second URL → "Bad
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
#ifndef _WIN32
cmd << " 2>&1";
#endif
if (std::getenv("FN_HTTP_DEBUG")) {
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
req.url.c_str(), req.url.size());
}
// Capture stderr (curl prints transport errors to stderr with -sS).
std::string curl_stderr;
+2 -2
View File
@@ -19,7 +19,7 @@ example: |
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::ifstream f("$HOME/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
auto title = std::get<std::string>(fm.fields["title"]);
@@ -65,7 +65,7 @@ Cuando una app C++ necesita leer metadata de archivos Markdown del registry (iss
#include <fstream>
#include <sstream>
std::ifstream f("/home/lucas/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::ifstream f("$HOME/fn_registry/dev/issues/0109-skill-tree-app-roadmap.md");
std::stringstream ss; ss << f.rdbuf();
auto fm = fn_md::parse_md_frontmatter(ss.str());
+42 -7
View File
@@ -14,9 +14,17 @@ static void create_tex(Framebuffer& f) {
glBindTexture(GL_TEXTURE_2D, 0);
}
static void create_depth_rbo(Framebuffer& f) {
glGenRenderbuffers(1, &f.depth_rbo);
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
}
void fb_init(Framebuffer& f) {
f.width = 1;
f.height = 1;
f.width = 1;
f.height = 1;
f.has_depth = false;
create_tex(f);
glGenFramebuffers(1, &f.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
@@ -24,23 +32,50 @@ void fb_init(Framebuffer& f) {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void fb_init_depth(Framebuffer& f) {
f.width = 1;
f.height = 1;
f.has_depth = true;
create_tex(f);
create_depth_rbo(f);
glGenFramebuffers(1, &f.fbo);
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void fb_resize(Framebuffer& f, int w, int h) {
if (w == f.width && h == f.height) return;
f.width = w;
f.width = w;
f.height = h;
// Recreate color texture.
if (f.tex) glDeleteTextures(1, &f.tex);
f.tex = 0;
create_tex(f);
glBindFramebuffer(GL_FRAMEBUFFER, f.fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, f.tex, 0);
// Resize depth renderbuffer in-place (no need to recreate).
if (f.has_depth && f.depth_rbo) {
glBindRenderbuffer(GL_RENDERBUFFER, f.depth_rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, f.width, f.height);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
// Re-attach in case it was lost (should be stable across storage resize, but be safe).
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, f.depth_rbo);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void fb_destroy(Framebuffer& f) {
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
f.width = 0;
f.height = 0;
if (f.fbo) { glDeleteFramebuffers(1, &f.fbo); f.fbo = 0; }
if (f.tex) { glDeleteTextures(1, &f.tex); f.tex = 0; }
if (f.depth_rbo) { glDeleteRenderbuffers(1, &f.depth_rbo); f.depth_rbo = 0; }
f.width = 0;
f.height = 0;
f.has_depth = false;
}
} // namespace fn::gfx
+10 -7
View File
@@ -3,14 +3,17 @@
namespace fn::gfx {
struct Framebuffer {
unsigned int fbo = 0;
unsigned int tex = 0; // GL_RGBA8, clamp, linear
int width = 0;
int height = 0;
unsigned int fbo = 0;
unsigned int tex = 0; // GL_RGBA8 color
unsigned int depth_rbo = 0; // GL_DEPTH_COMPONENT24 renderbuffer, 0 si sin depth
int width = 0;
int height = 0;
bool has_depth = false;
};
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 iniciales
void fb_resize(Framebuffer& f, int w, int h); // no-op si w,h iguales
void fb_destroy(Framebuffer& f);
void fb_init(Framebuffer& f); // crea fbo+tex 1x1 (color-only, retro-compat)
void fb_init_depth(Framebuffer& f); // crea fbo+tex+depth_rbo 1x1
void fb_resize(Framebuffer& f, int w, int h); // redimensiona color y depth (si has_depth); no-op si iguales
void fb_destroy(Framebuffer& f); // libera fbo, tex y depth_rbo si existen
} // namespace fn::gfx
+33 -10
View File
@@ -3,11 +3,11 @@ name: gl_framebuffer
kind: function
lang: cpp
domain: gfx
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "void fb_init(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8). fb_resize es no-op si las dimensiones no cambian. Listo para uso con ImGui::Image."
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen]
signature: "void fb_init(Framebuffer& f); void fb_init_depth(Framebuffer& f); void fb_resize(Framebuffer& f, int w, int h); void fb_destroy(Framebuffer& f)"
description: "CRUD de un framebuffer OpenGL (FBO + textura RGBA8, opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24). fb_init es color-only (retro-compat); fb_init_depth añade depth. fb_resize redimensiona color y depth si has_depth. Listo para uso con ImGui::Image."
tags: [opengl, framebuffer, fbo, texture, gfx, offscreen, depth, cpp-dashboard-viz]
uses_functions: ["gl_loader_cpp_gfx"]
uses_types: []
returns: []
@@ -21,23 +21,23 @@ file_path: "cpp/functions/gfx/gl_framebuffer.cpp"
framework: opengl
params:
- name: f
desc: "Struct Framebuffer con campos fbo, tex (GL ids), width, height. Inicializar a {0} antes de fb_init."
desc: "Struct Framebuffer con campos fbo, tex, depth_rbo (GL ids), width, height, has_depth. Inicializar a {0} antes de fb_init/fb_init_depth."
- name: w
desc: "Ancho deseado en pixels (fb_resize)"
- name: h
desc: "Alto deseado en pixels (fb_resize)"
output: "Modifica f in-place. Después de fb_init, f.fbo y f.tex son IDs GL válidos. fb_destroy pone todos los campos a 0."
output: "Modifica f in-place. Después de fb_init/fb_init_depth, f.fbo y f.tex son IDs GL válidos. Si fb_init_depth: f.depth_rbo != 0 y f.has_depth == true. fb_destroy pone todos los campos a 0."
---
# gl_framebuffer
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
FBO con textura color RGBA8 (GL_CLAMP_TO_EDGE, GL_LINEAR). Opcionalmente con depth renderbuffer GL_DEPTH_COMPONENT24. Diseñado para renderizado offscreen y posterior display via `ImGui::Image`.
## Ciclo de vida
## Ciclo de vida — color-only (retro-compat)
```cpp
fn::gfx::Framebuffer fb{};
fn::gfx::fb_init(fb); // fbo + tex 1x1
fn::gfx::fb_init(fb); // fbo + tex 1x1, has_depth=false
// En el render loop:
fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
@@ -46,6 +46,23 @@ fn::gfx::fb_resize(fb, w, h); // no-op si mismas dimensiones
fn::gfx::fb_destroy(fb);
```
## Ciclo de vida — con depth renderbuffer
```cpp
fn::gfx::Framebuffer fb{};
fn::gfx::fb_init_depth(fb); // fbo + tex 1x1 + depth_rbo 1x1, has_depth=true
// En el render loop (antes de glDrawElements):
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
fn::gfx::fb_resize(fb, w, h); // redimensiona color Y depth_rbo
// Al destruir:
fn::gfx::fb_destroy(fb); // libera fbo, tex y depth_rbo
```
## Uso con ImGui::Image
```cpp
@@ -59,4 +76,10 @@ ImGui::Image(
## Notas
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Esto minimiza el overhead de resize.
`fb_resize` recrea solo la textura (no el FBO) cuando las dimensiones cambian, reattachando la nueva textura al FBO existente. Para el depth renderbuffer, llama `glRenderbufferStorage` in-place (sin recrear el RBO). Esto minimiza el overhead de resize.
`fb_init` (sin depth) se mantiene idéntico al comportamiento pre-v1.1.0 — no rompe consumidores existentes (`shader_canvas`, `graph_renderer`).
## Capability growth log
v1.1.0 (2026-05-28) — fb_init_depth opcional + depth en fb_resize/fb_destroy
+510
View File
@@ -0,0 +1,510 @@
#include "gfx/gltf_load_mesh.h"
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
// nlohmann/json vendored
#include "nlohmann/json.hpp"
namespace fn::gfx {
// ---------------------------------------------------------------------------
// Thread-local last error
// ---------------------------------------------------------------------------
static thread_local char s_last_error[512] = "";
static void set_error(const char* msg) {
std::strncpy(s_last_error, msg, sizeof(s_last_error) - 1);
s_last_error[sizeof(s_last_error) - 1] = '\0';
}
const char* gltf_load_last_error() { return s_last_error; }
// ---------------------------------------------------------------------------
// GLB binary format constants (spec glTF 2.0)
// ---------------------------------------------------------------------------
static constexpr uint32_t GLB_MAGIC = 0x46546C67u; // "glTF"
static constexpr uint32_t GLB_VERSION = 2u;
static constexpr uint32_t CHUNK_JSON = 0x4E4F534Au; // "JSON"
static constexpr uint32_t CHUNK_BIN = 0x004E4942u; // "BIN\0"
// glTF accessor componentType
static constexpr int CT_UNSIGNED_BYTE = 5121;
static constexpr int CT_UNSIGNED_SHORT = 5123;
static constexpr int CT_UNSIGNED_INT = 5125;
static constexpr int CT_FLOAT = 5126;
// ---------------------------------------------------------------------------
// Math helpers
// ---------------------------------------------------------------------------
static void cross3(const float a[3], const float b[3], float out[3]) {
out[0] = a[1]*b[2] - a[2]*b[1];
out[1] = a[2]*b[0] - a[0]*b[2];
out[2] = a[0]*b[1] - a[1]*b[0];
}
static float dot3(const float a[3], const float b[3]) {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}
static float len3(const float a[3]) {
return std::sqrt(dot3(a, a));
}
// Multiply 4x4 column-major matrix by vec3 (point, w=1)
static void mat4_mul_point(const float m[16], const float p[3], float out[3]) {
out[0] = m[0]*p[0] + m[4]*p[1] + m[8] *p[2] + m[12];
out[1] = m[1]*p[0] + m[5]*p[1] + m[9] *p[2] + m[13];
out[2] = m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14];
}
// Multiply 4x4 column-major matrix by vec3 (direction, w=0 — for normals use
// inverse-transpose, which here is computed at call site)
static void mat4_mul_dir(const float m[16], const float v[3], float out[3]) {
out[0] = m[0]*v[0] + m[4]*v[1] + m[8] *v[2];
out[1] = m[1]*v[0] + m[5]*v[1] + m[9] *v[2];
out[2] = m[2]*v[0] + m[6]*v[1] + m[10]*v[2];
}
// 3x3 inverse-transpose (for normal transform) extracted from upper-left of 4x4.
// Returns false if matrix is singular (scale 0).
static bool compute_normal_matrix(const float m[16], float out[9]) {
// Extract upper-left 3x3 (column-major from 4x4)
float a00=m[0], a10=m[1], a20=m[2];
float a01=m[4], a11=m[5], a21=m[6];
float a02=m[8], a12=m[9], a22=m[10];
float det = a00*(a11*a22 - a21*a12)
- a01*(a10*a22 - a20*a12)
+ a02*(a10*a21 - a20*a11);
if (std::fabs(det) < 1e-12f) return false;
float inv = 1.0f / det;
// Inverse of 3x3, then transpose → inverse-transpose columns become rows
out[0] = inv * (a11*a22 - a21*a12);
out[1] = inv * (a21*a02 - a01*a22);
out[2] = inv * (a01*a12 - a11*a02);
out[3] = inv * (a20*a12 - a10*a22);
out[4] = inv * (a00*a22 - a20*a02);
out[5] = inv * (a10*a02 - a00*a12);
out[6] = inv * (a10*a21 - a20*a11);
out[7] = inv * (a20*a01 - a00*a21);
out[8] = inv * (a00*a11 - a10*a01);
return true;
}
static void nrm3x3_mul(const float m[9], const float v[3], float out[3]) {
out[0] = m[0]*v[0] + m[3]*v[1] + m[6]*v[2];
out[1] = m[1]*v[0] + m[4]*v[1] + m[7]*v[2];
out[2] = m[2]*v[0] + m[5]*v[1] + m[8]*v[2];
}
// TRS → column-major 4x4 matrix
// translation=[tx,ty,tz], rotation quaternion=[qx,qy,qz,qw], scale=[sx,sy,sz]
static void trs_to_mat4(const float t[3], const float q[4], const float s[3],
float out[16]) {
float qx=q[0], qy=q[1], qz=q[2], qw=q[3];
float x2=qx+qx, y2=qy+qy, z2=qz+qz;
float xx=qx*x2, xy=qx*y2, xz=qx*z2;
float yy=qy*y2, yz=qy*z2, zz=qz*z2;
float wx=qw*x2, wy=qw*y2, wz=qw*z2;
out[0] = (1-(yy+zz))*s[0]; out[1] = (xy+wz)*s[0]; out[2] = (xz-wy)*s[0]; out[3] = 0;
out[4] = (xy-wz)*s[1]; out[5] = (1-(xx+zz))*s[1]; out[6] = (yz+wx)*s[1]; out[7] = 0;
out[8] = (xz+wy)*s[2]; out[9] = (yz-wx)*s[2]; out[10] = (1-(xx+yy))*s[2]; out[11] = 0;
out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; out[15] = 1;
}
// ---------------------------------------------------------------------------
// Accessor reading helpers
// ---------------------------------------------------------------------------
struct BufView {
const uint8_t* base = nullptr;
size_t total = 0;
};
// Read a single element of 'count' components from accessor at element index 'idx'.
// component_type: CT_FLOAT, CT_UNSIGNED_BYTE, CT_UNSIGNED_SHORT, CT_UNSIGNED_INT
// components_per_element: 1 (SCALAR) or 3 (VEC3) etc.
// Returns false if out-of-bounds.
static bool read_float_vec(const BufView& bin,
int component_type,
int components_per_element,
size_t byte_offset, // accessor.byteOffset + bufferView.byteOffset
int byte_stride, // bufferView.byteStride (0 = tightly packed)
size_t idx,
float out[4]) {
size_t comp_size = 0;
switch (component_type) {
case CT_UNSIGNED_BYTE: comp_size = 1; break;
case CT_UNSIGNED_SHORT: comp_size = 2; break;
case CT_UNSIGNED_INT: comp_size = 4; break;
case CT_FLOAT: comp_size = 4; break;
default: return false;
}
size_t element_size = comp_size * (size_t)components_per_element;
size_t stride = (byte_stride > 0) ? (size_t)byte_stride : element_size;
size_t off = byte_offset + idx * stride;
if (off + element_size > bin.total) return false;
const uint8_t* p = bin.base + off;
for (int c = 0; c < components_per_element; ++c) {
const uint8_t* cp = p + (size_t)c * comp_size;
switch (component_type) {
case CT_UNSIGNED_BYTE: out[c] = (float)*cp; break;
case CT_UNSIGNED_SHORT: {
uint16_t v; std::memcpy(&v, cp, 2); out[c] = (float)v; break;
}
case CT_UNSIGNED_INT: {
uint32_t v; std::memcpy(&v, cp, 4); out[c] = (float)v; break;
}
case CT_FLOAT: {
float v; std::memcpy(&v, cp, 4); out[c] = v; break;
}
default: return false;
}
}
return true;
}
static bool read_index(const BufView& bin,
int component_type,
size_t byte_offset,
size_t idx,
uint32_t& out) {
float v[1] = {};
if (!read_float_vec(bin, component_type, 1, byte_offset, 0, idx, v))
return false;
out = static_cast<uint32_t>(v[0]);
return true;
}
// ---------------------------------------------------------------------------
// Core GLB parser
// ---------------------------------------------------------------------------
static Mesh parse_glb(const uint8_t* data, size_t size) {
s_last_error[0] = '\0';
// --- 1. Validate header (12 bytes) ---
if (size < 12) { set_error("file too small for GLB header"); return {}; }
uint32_t magic, version, total_len;
std::memcpy(&magic, data, 4);
std::memcpy(&version, data + 4, 4);
std::memcpy(&total_len, data + 8, 4);
if (magic != GLB_MAGIC) { set_error("not a GLB file (bad magic)"); return {}; }
if (version != GLB_VERSION){ set_error("unsupported GLB version (expected 2)"); return {}; }
if (total_len > size) { set_error("GLB total_length > buffer size"); return {}; }
// --- 2. Walk chunks ---
const uint8_t* json_data = nullptr; size_t json_len = 0;
const uint8_t* bin_data = nullptr; size_t bin_len = 0;
size_t pos = 12;
while (pos + 8 <= total_len) {
uint32_t chunk_len, chunk_type;
std::memcpy(&chunk_len, data + pos, 4);
std::memcpy(&chunk_type, data + pos + 4, 4);
pos += 8;
if (pos + chunk_len > total_len) { set_error("chunk extends past file end"); return {}; }
if (chunk_type == CHUNK_JSON) {
json_data = data + pos;
json_len = chunk_len;
} else if (chunk_type == CHUNK_BIN) {
bin_data = data + pos;
bin_len = chunk_len;
}
pos += chunk_len;
}
if (!json_data) { set_error("no JSON chunk found"); return {}; }
// --- 3. Parse JSON ---
nlohmann::json j;
try {
j = nlohmann::json::parse(json_data, json_data + json_len);
} catch (const std::exception& e) {
std::snprintf(s_last_error, sizeof(s_last_error), "JSON parse error: %s", e.what());
return {};
}
// --- 4. Find first mesh / first primitive ---
if (!j.contains("meshes") || j["meshes"].empty()) {
set_error("no meshes in glTF");
return {};
}
auto& prim = j["meshes"][0]["primitives"][0];
auto& attrs = prim["attributes"];
if (!attrs.contains("POSITION")) {
set_error("primitive has no POSITION attribute");
return {};
}
auto& accessors = j["accessors"];
auto& bufferViews = j["bufferViews"];
BufView bin_view { bin_data, bin_len };
// Helper: resolve accessor index → (byte_offset, byte_stride, component_type, count, components_per_elem)
struct AccInfo { size_t byte_offset; int byte_stride; int comp_type; size_t count; int ncomp; };
auto resolve_accessor = [&](int acc_idx, AccInfo& out) -> bool {
if (acc_idx < 0 || acc_idx >= (int)accessors.size()) return false;
auto& acc = accessors[acc_idx];
int bv_idx = acc.value("bufferView", -1);
size_t acc_offset = acc.value("byteOffset", 0);
out.comp_type = acc.value("componentType", 0);
out.count = acc.value("count", 0u);
std::string type_str = acc.value("type", "SCALAR");
out.ncomp = 1;
if (type_str == "VEC2") out.ncomp = 2;
else if (type_str == "VEC3") out.ncomp = 3;
else if (type_str == "VEC4") out.ncomp = 4;
if (bv_idx >= 0 && bv_idx < (int)bufferViews.size()) {
auto& bv = bufferViews[bv_idx];
size_t bv_offset = bv.value("byteOffset", 0u);
out.byte_stride = bv.value("byteStride", 0);
out.byte_offset = acc_offset + bv_offset;
} else {
out.byte_offset = acc_offset;
out.byte_stride = 0;
}
return out.count > 0 && out.comp_type != 0;
};
// --- 5. Read POSITION ---
AccInfo pos_info{};
if (!resolve_accessor(attrs["POSITION"].get<int>(), pos_info)) {
set_error("failed to resolve POSITION accessor");
return {};
}
if (pos_info.ncomp != 3 || pos_info.comp_type != CT_FLOAT) {
set_error("POSITION must be float vec3");
return {};
}
if (!bin_data && pos_info.count > 0) {
set_error("POSITION accessor requires BIN chunk, which is missing");
return {};
}
size_t nv = pos_info.count;
std::vector<float> positions(nv * 3);
for (size_t i = 0; i < nv; ++i) {
float v[4]{};
if (!read_float_vec(bin_view, CT_FLOAT, 3, pos_info.byte_offset,
pos_info.byte_stride, i, v)) {
set_error("out-of-bounds read in POSITION");
return {};
}
positions[i*3+0] = v[0];
positions[i*3+1] = v[1];
positions[i*3+2] = v[2];
}
// --- 6. Read NORMAL (optional) ---
std::vector<float> normals;
bool has_normals = false;
if (attrs.contains("NORMAL")) {
AccInfo nrm_info{};
if (resolve_accessor(attrs["NORMAL"].get<int>(), nrm_info) &&
nrm_info.ncomp == 3 && nrm_info.comp_type == CT_FLOAT &&
nrm_info.count == nv) {
normals.resize(nv * 3);
for (size_t i = 0; i < nv; ++i) {
float v[4]{};
if (!read_float_vec(bin_view, CT_FLOAT, 3, nrm_info.byte_offset,
nrm_info.byte_stride, i, v)) {
set_error("out-of-bounds read in NORMAL");
return {};
}
normals[i*3+0] = v[0];
normals[i*3+1] = v[1];
normals[i*3+2] = v[2];
}
has_normals = true;
}
}
// --- 7. Read indices ---
std::vector<uint32_t> indices;
if (prim.contains("indices") && !prim["indices"].is_null()) {
AccInfo idx_info{};
int idx_acc = prim["indices"].get<int>();
if (!resolve_accessor(idx_acc, idx_info)) {
set_error("failed to resolve indices accessor");
return {};
}
if (!bin_data && idx_info.count > 0) {
set_error("indices accessor requires BIN chunk, which is missing");
return {};
}
indices.resize(idx_info.count);
for (size_t i = 0; i < idx_info.count; ++i) {
if (!read_index(bin_view, idx_info.comp_type, idx_info.byte_offset, i, indices[i])) {
set_error("out-of-bounds read in indices");
return {};
}
}
} else {
// No indices: interpret as sequential triangle list
indices.resize(nv);
for (size_t i = 0; i < nv; ++i) indices[i] = (uint32_t)i;
}
// --- 8. Generate normals if missing (smooth, area-weighted) ---
if (!has_normals) {
normals.assign(nv * 3, 0.0f);
size_t ntri = indices.size() / 3;
for (size_t t = 0; t < ntri; ++t) {
uint32_t i0 = indices[t*3+0];
uint32_t i1 = indices[t*3+1];
uint32_t i2 = indices[t*3+2];
if (i0 >= nv || i1 >= nv || i2 >= nv) continue;
float e1[3] = {
positions[i1*3+0] - positions[i0*3+0],
positions[i1*3+1] - positions[i0*3+1],
positions[i1*3+2] - positions[i0*3+2]
};
float e2[3] = {
positions[i2*3+0] - positions[i0*3+0],
positions[i2*3+1] - positions[i0*3+1],
positions[i2*3+2] - positions[i0*3+2]
};
float face_n[3];
cross3(e1, e2, face_n);
// face_n magnitude = 2 * area → area weighting automatic
for (uint32_t vi : {i0, i1, i2}) {
normals[vi*3+0] += face_n[0];
normals[vi*3+1] += face_n[1];
normals[vi*3+2] += face_n[2];
}
}
// Normalize per-vertex
for (size_t i = 0; i < nv; ++i) {
float* n = &normals[i*3];
float l = len3(n);
if (l > 1e-8f) { n[0]/=l; n[1]/=l; n[2]/=l; }
else { n[0]=0; n[1]=1; n[2]=0; } // degenerate fallback
}
}
// --- 9. Apply node transform (first node referencing this mesh) ---
bool applied_transform = false;
if (j.contains("nodes") && !j["nodes"].empty()) {
auto& nodes = j["nodes"];
for (size_t ni = 0; ni < nodes.size() && !applied_transform; ++ni) {
auto& node = nodes[ni];
if (!node.contains("mesh") || node["mesh"].get<int>() != 0) continue;
float mat[16] = {
1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
}; // identity column-major
if (node.contains("matrix") && node["matrix"].is_array() && node["matrix"].size() == 16) {
for (int k = 0; k < 16; ++k)
mat[k] = node["matrix"][k].get<float>();
applied_transform = true;
} else {
float t[3] = {0,0,0}, q[4] = {0,0,0,1}, s[3] = {1,1,1};
bool has_trs = false;
if (node.contains("translation") && node["translation"].size() == 3) {
for (int k = 0; k < 3; ++k) t[k] = node["translation"][k].get<float>();
has_trs = true;
}
if (node.contains("rotation") && node["rotation"].size() == 4) {
for (int k = 0; k < 4; ++k) q[k] = node["rotation"][k].get<float>();
has_trs = true;
}
if (node.contains("scale") && node["scale"].size() == 3) {
for (int k = 0; k < 3; ++k) s[k] = node["scale"][k].get<float>();
has_trs = true;
}
if (has_trs) {
trs_to_mat4(t, q, s, mat);
applied_transform = true;
}
}
if (applied_transform) {
// Check if matrix is non-trivially identity
const float id[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
bool is_identity = true;
for (int k = 0; k < 16; ++k)
if (std::fabs(mat[k] - id[k]) > 1e-6f) { is_identity = false; break; }
if (!is_identity) {
float nrm_mat[9];
bool has_nrm_mat = compute_normal_matrix(mat, nrm_mat);
for (size_t vi = 0; vi < nv; ++vi) {
float p[3] = { positions[vi*3+0], positions[vi*3+1], positions[vi*3+2] };
float tp[3];
mat4_mul_point(mat, p, tp);
positions[vi*3+0] = tp[0];
positions[vi*3+1] = tp[1];
positions[vi*3+2] = tp[2];
if (has_nrm_mat) {
float n[3] = { normals[vi*3+0], normals[vi*3+1], normals[vi*3+2] };
float tn[3];
nrm3x3_mul(nrm_mat, n, tn);
float l = len3(tn);
if (l > 1e-8f) { tn[0]/=l; tn[1]/=l; tn[2]/=l; }
normals[vi*3+0] = tn[0];
normals[vi*3+1] = tn[1];
normals[vi*3+2] = tn[2];
}
}
}
}
}
}
Mesh m;
m.positions = std::move(positions);
m.normals = std::move(normals);
m.indices = std::move(indices);
return m;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size) {
return parse_glb(reinterpret_cast<const uint8_t*>(data), size);
}
Mesh gltf_load_mesh_from_file(const char* path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) {
std::snprintf(s_last_error, sizeof(s_last_error),
"cannot open file: %s", path);
return {};
}
auto file_size = f.tellg();
if (file_size <= 0) { set_error("file is empty"); return {}; }
f.seekg(0);
std::vector<uint8_t> buf((size_t)file_size);
if (!f.read(reinterpret_cast<char*>(buf.data()), file_size)) {
set_error("file read failed");
return {};
}
return parse_glb(buf.data(), buf.size());
}
} // namespace fn::gfx
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "gfx/mesh_obj_load.h" // fn::gfx::Mesh
#include <cstddef>
namespace fn::gfx {
// Carga el primer mesh (primera primitive del primer mesh) de un archivo GLB 2.0.
//
// Soporta:
// - POSITION (vec3 float, obligatorio)
// - NORMAL (vec3 float, opcional — si falta se generan normales smooth
// area-weighted promediando las normales de cara de cada vertice)
// - indices (ubyte/ushort/uint, escalares) — sin indices se interpreta como
// lista de triangulos directa.
//
// Node transform: si el primer nodo que referencia el mesh tiene matrix o TRS,
// se aplica a posiciones y normales (normales se transforman con la inversa transpuesta).
//
// Limitaciones (documentadas):
// - Solo GLB (binario). .gltf+.bin separado y data-URIs base64 no soportados.
// - Solo el primer mesh / primera primitive.
// - Sin texturas ni materiales (mesh viewer usa color uniforme).
// - Asume buffer 0 embebido en el chunk BIN.
//
// Retorna Mesh vacio (positions.empty()) si el parse falla.
// El detalle del error esta disponible via gltf_load_last_error().
Mesh gltf_load_mesh_from_file(const char* path);
// Variante pura (salvo el buffer): parsea GLB desde un bloque de memoria.
// 'data' debe vivir al menos mientras dure la llamada.
// Retorna Mesh vacio en fallo; gltf_load_last_error() da el detalle.
Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size);
// Descripcion del ultimo error de gltf_load_mesh_from_file /
// gltf_load_mesh_from_memory. Valida hasta la siguiente llamada a cualquiera
// de las dos funciones. Nunca retorna nullptr (puede ser "").
const char* gltf_load_last_error();
} // namespace fn::gfx
+101
View File
@@ -0,0 +1,101 @@
---
name: gltf_load_mesh
kind: function
lang: cpp
domain: gfx
version: "1.0.0"
purity: impure
signature: "Mesh gltf_load_mesh_from_file(const char* path); Mesh gltf_load_mesh_from_memory(const unsigned char* data, size_t size); const char* gltf_load_last_error()"
description: "Parser GLB 2.0 (glTF binario): carga el primer mesh/primitive a CPU como fn::gfx::Mesh. Soporta POSITION+NORMAL (vec3 float), indices ubyte/ushort/uint, node transform TRS/matrix. Genera normales smooth area-weighted si faltan. Sin dependencias externas — BIN chunk + nlohmann JSON vendored."
tags: [mesh, gltf, glb, 3d, loader, geometry, gfx, mesh-3d]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [gfx/mesh_obj_load.h, nlohmann/json.hpp, fstream, cstring, cmath]
tested: true
tests:
- "invalid magic -> empty Mesh + last_error set"
- "too-small buffer -> empty Mesh + last_error set"
- "triangle without NORMAL -> normals generated, correct count"
- "quad (2 triangles) -> positions.size()==12, indices.size()==6"
- "explicit normals -> passed through unchanged"
- "nonexistent file -> empty Mesh + last_error set"
test_file_path: "cpp/tests/test_gltf_load_mesh.cpp"
file_path: "cpp/functions/gfx/gltf_load_mesh.cpp"
framework: opengl
params:
- name: path
desc: "Ruta al archivo .glb. Solo GLB binario — .gltf+.bin separado y data-URI base64 no soportados."
- name: data
desc: "Puntero al buffer GLB en memoria. Debe vivir mientras dure la llamada."
- name: size
desc: "Longitud del buffer en bytes."
output: "fn::gfx::Mesh con positions/normals (stride 3, mismo length) y indices uint32 (tri-list). Mesh vacio (positions.empty()==true) si parse falla. gltf_load_last_error() devuelve descripcion del error."
notes: |
Usa fn::gfx::Mesh de mesh_obj_load.h — mismo struct que consume mesh_gpu_upload().
nlohmann vendored en cpp/vendor/nlohmann/json.hpp.
El parser no aloca heap mas alla del Mesh de salida + JSON temporal.
gltf_load_last_error() usa thread_local — seguro en multihilo siempre que
cada hilo llame sus propias funciones.
---
# gltf_load_mesh
Loader GLB 2.0 minimal para el registry. Parsea el contenedor GLB binario a mano
(header 12 bytes + chunks JSON + BIN) usando nlohmann para el JSON. KISS: sin
tinygltf ni dependencias extra.
## Ejemplo
```cpp
// Cargar .glb generado por TripoSR/trimesh y subir a GPU:
#include "gfx/gltf_load_mesh.h"
#include "gfx/mesh_gpu.h"
auto cpu = fn::gfx::gltf_load_mesh_from_file("model.glb");
if (cpu.positions.empty()) {
fprintf(stderr, "gltf load failed: %s\n", fn::gfx::gltf_load_last_error());
return;
}
// Subir a GPU (requiere contexto GL activo):
auto gpu = fn::gfx::mesh_gpu_upload(cpu);
if (!gpu.ok()) { /* fallo de upload GL */ return; }
glUseProgram(prog);
glBindVertexArray(gpu.vao);
glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
fn::gfx::mesh_gpu_destroy(gpu);
```
```cpp
// Desde memoria (ej. respuesta HTTP o embedding):
std::vector<unsigned char> glb_buf = download_glb(...);
auto cpu = fn::gfx::gltf_load_mesh_from_memory(glb_buf.data(), glb_buf.size());
```
## Cuando usarla
Cuando recibes un `.glb` (binario glTF 2.0) de un backend Python (TripoSR,
trimesh, open3d) y necesitas renderizarlo en una app ImGui via `mesh_gpu_upload`.
Tambien util para inspeccionar geometria en CPU sin subir a GPU.
## Limitaciones
- **Solo GLB binario**. `.gltf + .bin` separado: no soportado. Data URIs base64: no soportados.
- **Primer mesh, primera primitive**. Archivos con multiples meshes o materiales: solo se carga el primero.
- **Sin texturas ni materiales**. El Mesh solo contiene geometria (posicion + normal). El shader del viewer usa color uniforme.
- **Buffer unico embebido** (chunk BIN). Referencias a buffers externos: no soportadas.
- **Modo solo triangulos** (`"mode": 4`, default). Puntos, lineas, triangle-strip: no soportados.
## Gotchas
- `gltf_load_last_error()` es `thread_local`. Si usas multihilo, cada hilo tiene su propio error buffer — no compartas el puntero entre hilos.
- El puntero que devuelve `gltf_load_last_error()` se sobreescribe en la siguiente llamada a `gltf_load_mesh_from_*`. Copia el string si lo necesitas despues.
- Un `Mesh` retornado con `positions.empty() == true` es la senal de fallo — **no** lanzamos excepciones.
- Para archivos grandes (>50 MB) la lectura es un `std::vector<uint8_t>` completo en memoria. Para streaming, usa `gltf_load_mesh_from_memory` con tu propio buffer.
- El parser no valida que `indices` sean menores que `nv` en cada vertice — indices fuera de rango se saltan silenciosamente durante la generacion de normales pero pueden producir geometria incorrecta.
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: impure
signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)"
description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats."
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx]
tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx, mesh-3d]
uses_functions: ["gl_loader_cpp_gfx", "mesh_obj_load_cpp_gfx"]
uses_types: []
returns: []
+1 -1
View File
@@ -7,7 +7,7 @@ version: "1.0.0"
purity: pure
signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)"
description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega."
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d]
tags: [obj, mesh, parser, wavefront, loader, geometry, 3d, mesh-3d]
uses_functions: []
uses_types: []
returns: []
+5 -6
View File
@@ -105,7 +105,7 @@ GLuint compile_program() {
void ensure_init(Cache& c) {
if (c.initialized) return;
fn::gfx::gl_loader_init();
fn::gfx::fb_init(c.fb);
fn::gfx::fb_init_depth(c.fb);
c.program = compile_program();
if (c.program) {
c.loc_view = glGetUniformLocation(c.program, "u_view");
@@ -145,10 +145,9 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo);
glViewport(0, 0, w, h);
glClearColor(0.10f, 0.10f, 0.13f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// No depth attachment in our FBO — fall back to back-to-front-ish via
// GL_DEPTH_TEST off. For inspection meshes this is fine; documented.
glDisable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glUseProgram(c.program);
auto m = fn::core::orbit_camera_matrices(*cfg.cam);
@@ -183,7 +182,7 @@ void mesh_viewer(const char* id, const MeshViewerConfig& cfg) {
// Restore GL state.
glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo);
glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]);
if (prev_depth) glEnable(GL_DEPTH_TEST);
if (prev_depth) glEnable(GL_DEPTH_TEST); else glDisable(GL_DEPTH_TEST);
}
// Display.

Some files were not shown because too many files have changed in this diff Show More