Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02eed13913 | |||
| 5bbe45ca30 | |||
| 58c4bc5f05 | |||
| b837b8281a | |||
| 73e2f688b6 | |||
| 23333a03bd | |||
| 63031c26e0 | |||
| 1078b2d2e1 | |||
| 7a96f01a20 | |||
| bcdb51e1b8 | |||
| d3397fb17c | |||
| aa3bc6dad7 | |||
| 8f24dec23c | |||
| 4efbd61603 | |||
| 0b6b984dd3 | |||
| 071aa71a04 | |||
| 64330944e1 | |||
| 643d3a2abf | |||
| 118015062b | |||
| 1fa82447c2 | |||
| 281502ac92 | |||
| 3b662ac4c3 | |||
| 44e189c5cc | |||
| 580e4ba1fd | |||
| c8bb3e7044 | |||
| e72e526c64 | |||
| 66f5ca1a4f | |||
| b9810a88d4 | |||
| 76215765a7 | |||
| 4456d58abe | |||
| c4c49d1813 | |||
| d2a244a765 | |||
| d854bcbae9 | |||
| 4268b6f187 | |||
| 7159ee6dcb | |||
| e7ab06ee29 | |||
| 14cd888c2e | |||
| b208517e0f | |||
| adbe8c889c | |||
| e0e037c869 | |||
| f61d2834e8 | |||
| 24efee80e2 | |||
| d317900eea | |||
| da6a8b5e59 | |||
| 5d5b1d3fea | |||
| bae4f45268 | |||
| 439d3776a3 | |||
| b093c898a8 | |||
| d3d5af51f2 | |||
| 0c7a8393ab | |||
| e5d2201377 | |||
| a6941b55c4 | |||
| 087412d73a | |||
| 61a238b3fd | |||
| 07a653d97d | |||
| 24905eebc7 | |||
| f8a54942ee | |||
| efadd0c0a4 | |||
| 099be06409 | |||
| d1d20bdc04 | |||
| 83f64aa3e1 | |||
| 30c1289434 | |||
| a11a58dab0 | |||
| 7d598c7345 | |||
| ac65791663 | |||
| a209afa46b | |||
| 426a842e2b | |||
| 075088b7aa | |||
| 144b15f0ce | |||
| c2e4f6a9e1 | |||
| e3cfab3dc7 | |||
| e828af3ac1 | |||
| 42957d10f6 | |||
| 2b55a4823d | |||
| 8eebd1abce | |||
| 3f622561ce | |||
| 6f269949f1 | |||
| bdce314199 | |||
| e115c2e3fd | |||
| 4610bb4a99 | |||
| b828fd6acc | |||
| 9b1ca41c4d | |||
| 3008b56e76 | |||
| d7c3daaa6b | |||
| e39445dd55 | |||
| 652ee19f29 | |||
| 32fc008cae | |||
| 28ff9c3f79 | |||
| ab226d7137 | |||
| 8a96ebe412 | |||
| a402192e73 | |||
| f79f2e757c | |||
| ca7a5874e4 | |||
| ca07927d38 | |||
| d771c21a46 | |||
| 4aa3bc2d94 | |||
| 7bda65209c | |||
| c25f623355 | |||
| 3b37827d16 | |||
| bcfe87af7f | |||
| 526d7f4977 | |||
| 6d63f058de | |||
| e19bc09f4d | |||
| 7eab4a52e9 | |||
| 057f55c8a9 | |||
| 7b2004c649 | |||
| 6c83263d9b | |||
| f46fde3656 | |||
| 4601af88b5 | |||
| fc1ebb4967 | |||
| 07341aa89f | |||
| 4bc6d1bced | |||
| 5f282bedc5 | |||
| 3b2cd26a06 | |||
| 66e54f092d | |||
| 22994f14bf | |||
| e96f8eaf6a | |||
| 5bbdf2ff16 | |||
| 19722cb085 | |||
| 6fac9e1ef0 | |||
| 1ab39d105a | |||
| 2c1a956b32 | |||
| e35ec39c10 | |||
| 637bc8fd34 | |||
| 75157f528a | |||
| 77be3ce325 | |||
| 9634cfdb4a | |||
| 6cf006d87b | |||
| 4d25ebd070 | |||
| 0bd91f04b8 | |||
| 0bfe267501 | |||
| 4b420fb24b | |||
| 3262d058a6 | |||
| 69dcfec4eb | |||
| 31708d0942 | |||
| 53976c0c31 | |||
| 04c3ead5fa | |||
| e076901aa9 | |||
| d80f0412a8 | |||
| 9e8c0d66bb | |||
| df0227d4f2 | |||
| ae22787e60 | |||
| ab3069ae17 | |||
| 1675d2bb84 | |||
| 4ac93a0933 | |||
| ae0c4b7389 | |||
| 3d47e74ec7 | |||
| 0255207514 | |||
| 95826cb14f | |||
| ad8ce45865 | |||
| fee892f38e | |||
| dcefa13d2d | |||
| f8aa5e8072 | |||
| bb15b142bf | |||
| 28364cf212 | |||
| 295ab491a3 | |||
| debbdb86be | |||
| 58539f45c9 | |||
| 4299482b75 | |||
| 7081c3b4d1 | |||
| cb25bf6d1b | |||
| e5c17f89d7 | |||
| 9a28d08e38 | |||
| baa72e211e | |||
| 58fab5ad34 | |||
| 854f42ed6b | |||
| 1f59b5b4c3 | |||
| e74ed2e7d3 | |||
| 93ae1bd497 | |||
| b0038aab43 | |||
| 3bb0c7c6f2 | |||
| fb9a598aa9 | |||
| aed8d5b308 | |||
| 6aacdb0323 | |||
| 116bbb5e87 | |||
| 2fd6eeb95b | |||
| cdad1b5832 | |||
| 9747069182 | |||
| a97dd9d9f5 | |||
| 38ac24a0ed | |||
| 851732ce7d | |||
| ff7da29638 | |||
| 94be3b62e7 | |||
| df424f2de0 | |||
| 7670b671f2 | |||
| 4ef5c6e5b8 | |||
| af9ad48c9b | |||
| 327937124f | |||
| 3d515aa441 | |||
| 9b0e1f836d | |||
| ca15655268 | |||
| ab868bcea7 | |||
| cdcdb04d01 | |||
| 53deb8e9a8 | |||
| 38fbb222bf | |||
| f7a4f26cf0 | |||
| 59eea5d0f1 | |||
| de64da7bbc | |||
| bb9c3d1bc3 | |||
| 97512e9a48 | |||
| 8c1315b9d2 | |||
| 02226d61f6 | |||
| fd19cd222a | |||
| d8d72bb8d6 | |||
| 092f14eff0 | |||
| adfd5f63bb | |||
| 74c9e39b58 | |||
| ccd123e062 | |||
| 6877fcc70a | |||
| 3ebda4fcca | |||
| f9c1280964 | |||
| d2cbbdf600 | |||
| b717337b7b | |||
| 5b375cb822 | |||
| ee4e86ee2e | |||
| 5f8b71b528 | |||
| 1e2582b068 | |||
| ae33d02e75 | |||
| a06946e410 | |||
| 6f6bc714a9 | |||
| 54e62ecb91 | |||
| 1a3e77b0d5 | |||
| 8bc721d53b | |||
| 6d73e1b4be | |||
| f2753e6fff | |||
| 773bb3a523 | |||
| ae1c69eee0 | |||
| e76a5e5ab1 | |||
| 94efefc7bf | |||
| 8f45b40528 | |||
| ac9965220d | |||
| 1344e557e5 | |||
| 2721b9cc8f | |||
| d9414e4cba | |||
| 7aa7790931 | |||
| c3dfc9315f | |||
| cb96e85b69 |
+34
-7
@@ -3,9 +3,11 @@
|
|||||||
Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes.
|
Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes.
|
||||||
|
|
||||||
**Dos bases de datos SQLite:**
|
**Dos bases de datos SQLite:**
|
||||||
- **registry.db** (raiz) — funciones, tipos, proposals. Regenerable con `fn index` (excepto proposals).
|
- **registry.db** (raiz) — funciones, tipos, proposals, apps, projects, analysis, vaults, pc_locations. Regenerable con `fn index` (excepto proposals y pc_locations).
|
||||||
- **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos.
|
- **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos.
|
||||||
|
|
||||||
|
**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token).
|
||||||
|
|
||||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -66,6 +68,13 @@ sqlite3 registry.db ".schema"
|
|||||||
- Extraidos automaticamente por `fn index` desde los archivos de test
|
- Extraidos automaticamente por `fn index` desde los archivos de test
|
||||||
- FK: `function_id` → `functions.id`
|
- FK: `function_id` → `functions.id`
|
||||||
|
|
||||||
|
**pc_locations** — columnas: `id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at`
|
||||||
|
- Mapa de ubicaciones por PC: donde esta cada app/analysis/project/vault en cada maquina
|
||||||
|
- `entity_type`: app, analysis, project, vault
|
||||||
|
- `status`: active, missing, archived
|
||||||
|
- Se puebla con `fn sync`, NO con `fn index`
|
||||||
|
- Consultas: `SELECT * FROM pc_locations WHERE pc_id = 'home-wsl'`
|
||||||
|
|
||||||
**FTS5 (columnas buscables):**
|
**FTS5 (columnas buscables):**
|
||||||
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code, params_schema
|
- `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code, params_schema
|
||||||
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
|
- `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code
|
||||||
@@ -93,6 +102,7 @@ fn-registry/
|
|||||||
cmd/fn/ # CLI principal
|
cmd/fn/ # CLI principal
|
||||||
docs/ # Specs de diseño
|
docs/ # Specs de diseño
|
||||||
docs/templates/ # Plantillas de frontmatter
|
docs/templates/ # Plantillas de frontmatter
|
||||||
|
temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -141,6 +151,13 @@ fn proposal list [-k kind] [-s status]
|
|||||||
fn proposal show <id>
|
fn proposal show <id>
|
||||||
fn proposal update <id> --status approved [--reviewed-by lucas]
|
fn proposal update <id> --status approved [--reviewed-by lucas]
|
||||||
|
|
||||||
|
# Sync entre PCs
|
||||||
|
fn sync # Push+pull completo contra el servidor
|
||||||
|
fn sync status # Estado local: PC, API, conteos
|
||||||
|
fn sync locations # Mapa de ubicaciones en todos los PCs
|
||||||
|
# Config: ~/.fn_pc (identidad PC), FN_REGISTRY_API (URL), REGISTRY_API_TOKEN (token)
|
||||||
|
# URL con basicAuth: export FN_REGISTRY_API="https://user:pass@registry.organic-machine.com"
|
||||||
|
|
||||||
# Operations (desde directorio con operations.db)
|
# Operations (desde directorio con operations.db)
|
||||||
fn ops init [path]
|
fn ops init [path]
|
||||||
fn ops entity add|list|show|delete
|
fn ops entity add|list|show|delete
|
||||||
@@ -235,16 +252,26 @@ analysis/
|
|||||||
|
|
||||||
### Crear un analisis nuevo
|
### Crear un analisis nuevo
|
||||||
|
|
||||||
```bash
|
Un solo comando deja todo listo: carpetas, venv, paquetes, launcher, MCP, kernel startup, `analysis.md` con frontmatter y, si va en un proyecto, `fn index` final.
|
||||||
# Basico
|
|
||||||
fn run init_jupyter_analysis finanzas
|
|
||||||
|
|
||||||
# Con paquetes extra
|
```bash
|
||||||
|
# Analisis suelto (analysis/{nombre}/)
|
||||||
|
fn run init_jupyter_analysis finanzas
|
||||||
fn run init_jupyter_analysis ml scikit-learn torch
|
fn run init_jupyter_analysis ml scikit-learn torch
|
||||||
fn run init_jupyter_analysis duckdb polars duckdb
|
|
||||||
|
# Analisis dentro de un proyecto (projects/{proyecto}/analysis/{nombre}/)
|
||||||
|
fn run init_jupyter_analysis --project aurgi sale_prices --desc "Comprobacion precios"
|
||||||
|
fn run init_jupyter_analysis --project fn_monitoring coverage polars --tags "monitoring,coverage"
|
||||||
```
|
```
|
||||||
|
|
||||||
El pipeline `init_jupyter_analysis_bash_pipelines` compone 8 funciones atomicas del registry.
|
Flags del pipeline:
|
||||||
|
- `--project <nombre>` — crea el analisis dentro de `projects/{nombre}/analysis/` y ejecuta `fn index` al final. El proyecto debe existir (`projects/{nombre}/project.md`).
|
||||||
|
- `--desc "..."` — descripcion que se escribe en el frontmatter de `analysis.md`.
|
||||||
|
- `--tags "a,b,c"` — tags CSV que se escriben en el frontmatter.
|
||||||
|
|
||||||
|
**NUNCA** uses `mv` para mover un analisis de `analysis/` a `projects/{proyecto}/analysis/` despues de crearlo. Al mover, el `.venv/bin/activate` queda con el path antiguo hardcodeado y el launcher falla con `ERROR: jupyter-collaboration no esta instalado`. Si esto pasa: `rm -rf .venv && uv sync` dentro del directorio nuevo. La forma correcta es siempre crear con `--project` desde el inicio.
|
||||||
|
|
||||||
|
El pipeline `init_jupyter_analysis_bash_pipelines` (v1.1.0) compone 9 funciones atomicas del registry.
|
||||||
|
|
||||||
### Usar un analisis
|
### Usar un analisis
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# /documentar — Distribuir la conversacion en la documentacion del registry
|
||||||
|
|
||||||
|
Documenta la **conversacion actual** repartiendo el contenido en TODOS los `.md` que correspondan: artefactos del registry (funciones, tipos, apps, projects, analysis, vaults) **y documentacion global del repo** (`docs/*`, `docs/adr/`, `CHANGELOG.md`, `dev/issues/*`, `.claude/rules/*`, `.claude/CLAUDE.md`, sub-CLAUDEs, READMEs/SPECs en apps). Cierra con una entrada en `/entrada_diario`. El objetivo es que **otro LLM (o yo en otra sesion) pueda continuar** sin haber visto la conversacion: contexto, decisiones, gotchas, paths, IDs, comandos exactos, "lo siguiente que pega".
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
/documentar # documenta todo lo relevante de la sesion
|
||||||
|
/documentar shaders_lab fase 6 # acota a artefactos/temas concretos (opcional)
|
||||||
|
```
|
||||||
|
|
||||||
|
`$ARGUMENTS` es opcional: si va vacio, documenta toda la sesion. Si lleva texto, usalo como hilo conductor para decidir que es relevante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas duras
|
||||||
|
|
||||||
|
1. **NUNCA** escribir secretos en ningun `.md` ni en el diario:
|
||||||
|
- Passwords, tokens, API keys, GPG keys, ssh private keys, valores reales de variables de entorno sensibles (`REGISTRY_API_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_TOKEN`, basicAuth en URLs).
|
||||||
|
- Si el usuario lo pide explicitamente, OK. Por defecto, redactar como `<token>` / `<password>` o referenciar el origen (`pass entry registry_api`, `~/.fn_pc`).
|
||||||
|
- URLs publicas, hosts, puertos, paths, IDs, nombres de servicios, env var **names** (no values), licencias, hashes de commit cortos: SI se documentan.
|
||||||
|
2. **NUNCA** sobreescribir secciones existentes ni reordenar contenido previo. Solo **append** o seccion nueva con timestamp/fase si encaja.
|
||||||
|
3. **SIEMPRE** consultar `registry.db` con FTS5 para encontrar el `.md` correcto antes de editar (no asumir paths).
|
||||||
|
4. **SIEMPRE** cerrar invocando `/entrada_diario` con un resumen del bloque (a no ser que el usuario diga lo contrario).
|
||||||
|
5. **Densidad util > prosa**: comandos exactos, IDs del registry, paths relativos, error messages literales, flags de build, decisiones (con el "porque"), bugs encontrados (con el fix), proximos pasos. Sin fluff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 0 — Recopilar el material de la sesion
|
||||||
|
|
||||||
|
Antes de escribir nada, repasar la conversacion y juntar:
|
||||||
|
|
||||||
|
1. **Artefactos tocados** (creados, editados, ejecutados, mencionados):
|
||||||
|
- Funciones / tipos del registry → IDs `{name}_{lang}_{domain}`.
|
||||||
|
- Apps (`apps/*` o `projects/*/apps/*`).
|
||||||
|
- Projects (`projects/*`).
|
||||||
|
- Analyses (`analysis/*` o `projects/*/analysis/*`).
|
||||||
|
- Vaults (`projects/*/vaults/vault.yaml`).
|
||||||
|
- Reglas (`.claude/rules/*.md`), ADRs (`docs/adr/*.md`), templates (`docs/templates/*`).
|
||||||
|
- Issues (`dev/issues/*.md`, `dev/issues/completed/*.md`).
|
||||||
|
- Docs globales (`docs/*.md`: architecture, integrity, execution_standard, fn_operations, sync_setup, init-pipelines, testing, functions, types, fn-registry-system-complete).
|
||||||
|
- CLAUDE.md raiz (`.claude/CLAUDE.md`) y sub-CLAUDEs (`apps/*/.claude/CLAUDE.md`, `projects/*/apps/*/.claude/CLAUDE.md`, `analysis/*/.claude/CLAUDE.md`, `projects/*/analysis/*/.claude/CLAUDE.md`).
|
||||||
|
- Docs sueltas en apps (`apps/*/SPEC.md`, `apps/*/README.md`, `apps/*/docs/*.md`, `cpp/DESIGN_SYSTEM.md`).
|
||||||
|
- `CHANGELOG.md` raiz.
|
||||||
|
|
||||||
|
2. **Cambios concretos** desde git:
|
||||||
|
```bash
|
||||||
|
cd /home/lucas/fn_registry
|
||||||
|
git status --short
|
||||||
|
git diff --stat
|
||||||
|
git log --since="6 hours ago" --oneline
|
||||||
|
```
|
||||||
|
Cada path modificado mapea a un artefacto — convertir a su `.md`.
|
||||||
|
|
||||||
|
3. **Material no codigo** que vale la pena dejar registrado:
|
||||||
|
- Decisiones de diseño y por que (anti-bitrot: el porque suele perderse).
|
||||||
|
- Bugs encontrados + raiz + fix (no solo "fix").
|
||||||
|
- Atajos / convenciones nuevas.
|
||||||
|
- Pendientes y "lo siguiente que pega" para la proxima sesion.
|
||||||
|
- Aprendizajes operativos (build flags, cross-compile gotchas, env requerido).
|
||||||
|
|
||||||
|
4. **Filtrar secretos** segun la regla dura #1.
|
||||||
|
|
||||||
|
Si el material es solo conversacion exploratoria sin artefactos tocados, ir directo a PASO 4 (solo diary).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 1 — Mapear cada bloque de informacion a su `.md`
|
||||||
|
|
||||||
|
Para cada artefacto identificado, localizar su `.md` consultando `registry.db`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lucas/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*');"
|
||||||
|
sqlite3 registry.db "SELECT id, file_path FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:NAME* OR description:NAME*');"
|
||||||
|
|
||||||
|
# App / project / analysis (los .md son nombres fijos)
|
||||||
|
sqlite3 registry.db "SELECT id, dir_path FROM apps WHERE name = 'NAME';" # → {dir_path}/app.md
|
||||||
|
sqlite3 registry.db "SELECT id, dir_path FROM projects WHERE name = 'NAME';" # → projects/NAME/project.md
|
||||||
|
sqlite3 registry.db "SELECT id, dir_path FROM analysis WHERE name = 'NAME';" # → {dir_path}/analysis.md
|
||||||
|
sqlite3 registry.db "SELECT id, name, path FROM vaults WHERE name = 'NAME';" # → vault.yaml entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el `.md` aun no existe (artefacto recien creado en la sesion y todavia no indexado), el path se deduce de la convencion:
|
||||||
|
- Funcion: `functions/{domain}/{name}.md`, `python/functions/{domain}/{name}.md`, `bash/functions/{domain}/{name}.md`, `frontend/functions/{domain}/{name}.md`, `cpp/functions/{domain}/{name}.md`.
|
||||||
|
- Tipo: `types/{domain}/{name}.md` (codigo en `functions/{domain}/{name}.go`).
|
||||||
|
- App: `apps/{name}/app.md` o `projects/{proyecto}/apps/{name}/app.md`.
|
||||||
|
- Project: `projects/{name}/project.md`.
|
||||||
|
- Analysis: `analysis/{name}/analysis.md` o `projects/{proyecto}/analysis/{name}/analysis.md`.
|
||||||
|
|
||||||
|
### Donde escribir dentro de cada `.md`
|
||||||
|
|
||||||
|
| Tipo de `.md` | Seccion preferida para append |
|
||||||
|
|-----------------------|----------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Funcion / tipo | `## Notas` al final. Si no existe, crearla. NO tocar el frontmatter salvo que el usuario pida cambiar metadata. |
|
||||||
|
| App (`app.md`) | `## Estado actual` con sub-fases si el app ya las usa (ej. `### Fase 7 — ... [done]`). Si no, `## Notas`. Tambien `## Lo siguiente que pega` para futuros pasos. |
|
||||||
|
| Project (`project.md`)| `## Notas` o seccion del area afectada (`## Apps`, `## Operacion`, `## Troubleshooting`). |
|
||||||
|
| Analysis (`analysis.md`)| `## Notas` o `## Hallazgos` (crearla si no existe). |
|
||||||
|
| Vault (`vault.yaml`) | Comentario al final del entry o crear `vaults/{name}/README.md` con notas operativas (NO meter datos sensibles). |
|
||||||
|
| Regla (`.claude/rules/*`)| Solo si el usuario explicitamente formaliza una regla nueva — entonces archivo nuevo + entrada en `INDEX.md`. |
|
||||||
|
| ADR (`docs/adr/*`) | Solo si la decision es arquitectural y persistente — archivo nuevo numerado. |
|
||||||
|
|
||||||
|
### Documentacion global / cross-cutting (NO saltarse)
|
||||||
|
|
||||||
|
Estos `.md` describen el sistema entero, no un artefacto concreto. Cuando un cambio impacta convenciones, comportamiento de agentes, decisiones, issues abiertos o features visibles al usuario, **tambien** se actualizan aqui:
|
||||||
|
|
||||||
|
| Archivo / carpeta | Cuando tocarlo | Como |
|
||||||
|
|---------------------------------------|-----------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
|
||||||
|
| `CHANGELOG.md` (raiz) | Cambio visible al usuario o agentes: nueva funcion/pipeline/app, breaking change, fix relevante, rename, deprecate. | Append bajo seccion del dia (`## YYYY-MM-DD`) con `### Added/Changed/Fixed/Removed/Deprecated`. NUNCA reescribir entradas previas. Si es trabajo en curso, usar `## [Unreleased]`. |
|
||||||
|
| `docs/adr/NNNN-slug.md` | **Decision arquitectural** persistente con alternativas descartadas (no es regla operativa, es historia del por que). | Archivo nuevo numerado siguiendo plantilla en `docs/adr/README.md`. Estado inicial: `accepted` o `proposed`. |
|
||||||
|
| `docs/architecture.md` | Cambia la arquitectura general (BDs, layers, flujo de datos, capas). | Append en seccion afectada o nueva subseccion. Mantener tablas y diagramas existentes. |
|
||||||
|
| `docs/integrity.md` | Nueva regla de integridad / referencia cruzada que el indexer valida. | Append a la lista de reglas. Reflejar tambien en codigo del indexer si toca. |
|
||||||
|
| `docs/execution_standard.md` | Cambia el estandar de ejecucion (`fn run`, despacho por lenguaje, env vars). | Append seccion. Sincronizar con `.claude/CLAUDE.md` si menciona los mismos comandos. |
|
||||||
|
| `docs/sync_setup.md` | Cambia el flujo de `fn sync`, env vars (`FN_REGISTRY_API`, `REGISTRY_API_TOKEN`), `~/.fn_pc`, troubleshooting. | Append. Recordatorio: NO escribir el valor del token, solo el nombre. |
|
||||||
|
| `docs/init-pipelines.md` | Nuevo pipeline de scaffolding o cambio en uno existente. | Append seccion del pipeline. |
|
||||||
|
| `docs/testing.md` | Cambia convencion de tests, runners, layout de `*_test.go`/`test_*.py`. | Append seccion afectada. |
|
||||||
|
| `docs/functions.md` / `docs/types.md` | Cambia el schema de la tabla `functions` o `types` (columnas, FTS5, enums, `params_schema`). | Append. Sincronizar con `.claude/CLAUDE.md` schema rapido. |
|
||||||
|
| `docs/fn_operations.md` | Cambia el schema/comportamiento de `operations.db` o el bucle reactivo (entities, relations, executions, assertions). | Append seccion afectada. |
|
||||||
|
| `docs/fn-registry-system-complete.md` | Snapshot completo del sistema — solo si la sesion implico un rediseño grande. Normalmente NO se toca por sesion. | Si toca, append seccion con timestamp. |
|
||||||
|
| `docs/templates/*.md` | Cambia el frontmatter obligatorio de un tipo de artefacto (function/pipeline/component/type/app/project/analysis). | Editar la plantilla correspondiente. Tambien actualizar ejemplos en `.claude/CLAUDE.md`. |
|
||||||
|
| `dev/issues/NNNN-*.md` | Sesion trabajo en un issue: progreso, blockers, decisiones del scope. | Append `## Notas / Progreso` con timestamp. NO mover de `dev/issues/` a `dev/issues/completed/` salvo que el issue cierre. |
|
||||||
|
| `dev/issues/completed/NNNN-*.md` | Issue completado en esta sesion. | Mover el archivo a `completed/` (`git mv`) y actualizar la fila en `dev/issues/README.md` (estado `completado`, link a `completed/...`). |
|
||||||
|
| `dev/issues/README.md` | Issue creado, cambia estado, prioridad, dependencias. | Editar la fila correspondiente o anadir nueva al final de la tabla. |
|
||||||
|
| `.claude/rules/*.md` + `INDEX.md` | El usuario formaliza una nueva regla operativa. | Archivo nuevo + fila en `INDEX.md`. Numerar en el indice manteniendo orden. |
|
||||||
|
| `.claude/CLAUDE.md` (raiz) | Cambio en convenciones globales del proyecto, comandos `fn` nuevos, env vars, estructura de carpetas, schema BDs. | Append en seccion afectada. Sincronizar con `docs/` si hay overlap. |
|
||||||
|
| Sub-CLAUDE (`apps/*/.claude/CLAUDE.md`, `analysis/*/.claude/CLAUDE.md`, `projects/*/apps/*/.claude/CLAUDE.md`) | Cambio especifico en como un agente debe trabajar dentro de esa app/analysis (no global). | Append. NO duplicar reglas que ya estan en CLAUDE.md raiz. |
|
||||||
|
| `cpp/DESIGN_SYSTEM.md` | Cambia tokens, layout, primitivas visuales del stack C++. | Append seccion afectada. |
|
||||||
|
| `apps/*/SPEC.md`, `apps/*/README.md`, `apps/*/docs/*.md`, `apps/*/NEXT_STEPS_*.md` | App tiene docs propias mas alla de `app.md`. | Append. Si el contenido encaja mejor en `app.md`, preferir `app.md` y mencionar el SPEC desde ahi. |
|
||||||
|
|
||||||
|
### Reglas de decision rapidas
|
||||||
|
|
||||||
|
- **¿Cambio visible / breaking / nueva feature?** → `CHANGELOG.md` SI.
|
||||||
|
- **¿Decision con alternativas descartadas?** → ADR SI. Una regla operativa "haz X" sin alternativas → `.claude/rules/`.
|
||||||
|
- **¿Cambia como un agente debe comportarse?** → `.claude/rules/` o `.claude/CLAUDE.md` (global) o sub-CLAUDE (local).
|
||||||
|
- **¿Cambia el schema de BDs o columnas?** → `docs/functions.md`/`docs/types.md`/`docs/fn_operations.md` + `.claude/CLAUDE.md` schema rapido.
|
||||||
|
- **¿Trabajo en un issue?** → `dev/issues/NNNN-*.md` + tabla en `dev/issues/README.md`.
|
||||||
|
- **¿Cross-cutting sin artefacto y sin encajar arriba?** → solo diario (PASO 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 2 — Escribir las actualizaciones
|
||||||
|
|
||||||
|
Para cada `.md` identificado:
|
||||||
|
|
||||||
|
1. `Read` el archivo para ver estructura actual y secciones.
|
||||||
|
2. Decidir si **append a seccion existente** o **crear seccion nueva**.
|
||||||
|
3. Usar `Edit` para append (preferible) o `Write` solo si es archivo nuevo.
|
||||||
|
4. **Mantener el estilo** del archivo (markdown, viñetas cortas, bloques de codigo con lenguaje).
|
||||||
|
5. **No tocar el frontmatter** salvo que el usuario haya cambiado metadata explicita (`description`, `tags`, `uses_functions`, `version`). Si se toca, re-indexar al final.
|
||||||
|
|
||||||
|
### Plantilla de bloque para append en `.md` de artefacto
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
|
||||||
|
### {Fase / Tema corto} `[done|wip|notes]`
|
||||||
|
|
||||||
|
{1-3 lineas de contexto: que se hizo y por que.}
|
||||||
|
|
||||||
|
- Hecho: {cambio concreto, con path y/o ID si aplica}.
|
||||||
|
- Hecho: {cambio concreto}.
|
||||||
|
- Bug + fix: {sintoma → raiz → fix} (si procede).
|
||||||
|
- Decision: {opcion elegida vs alternativa} — porque {razon} (si procede).
|
||||||
|
- Pendiente: {algo que queda} (si procede).
|
||||||
|
|
||||||
|
{Comando(s) exacto(s) si la operacion vale la pena reproducir.}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plantilla para `## Lo siguiente que pega` (apps maduras)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- {Tarea proxima}: {contexto minimo, que tocar, criterio de hecho}.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 3 — Reindexar si tocaste frontmatter o creaste artefacto
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Y verificar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn show {id_creado_o_modificado}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si solo se editaron secciones de prosa (Notas, Estado actual, etc.) sin tocar frontmatter, el indexado igual recoge `documentation`/`notes` actualizados — re-indexar es barato y deja la BD coherente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 4 — Cerrar con entrada al diario
|
||||||
|
|
||||||
|
Invocar `/entrada_diario` con un resumen conciso de la sesion (3-6 viñetas, verbos en pasado para lo hecho, infinitivo para pendientes). Referenciar:
|
||||||
|
|
||||||
|
- IDs de artefactos tocados.
|
||||||
|
- Paths relativos clave.
|
||||||
|
- Hashes de commit cortos si la sesion termino con commits.
|
||||||
|
- ADRs / issues / proposals abiertos.
|
||||||
|
|
||||||
|
Ejemplo de invocacion:
|
||||||
|
|
||||||
|
```
|
||||||
|
/entrada_diario shaders_lab fase 6 — menubar reusable (View + Layouts) cableado, persistencia de layouts en shaders_lab.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el usuario ya invoco `/entrada_diario` antes en esta sesion para este bloque, **no duplicar**: solo añadir lo que no estaba.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 5 — Reportar al usuario
|
||||||
|
|
||||||
|
Resumen breve (formato texto, no tabla a no ser que sean muchos):
|
||||||
|
|
||||||
|
```
|
||||||
|
=== DOCUMENTADO ===
|
||||||
|
|
||||||
|
Artefactos (.md de registry):
|
||||||
|
- apps/shaders_lab/app.md (Fase 6 — menubar)
|
||||||
|
- cpp/functions/core/app_menubar.md (notas de uso)
|
||||||
|
- cpp/functions/core/layouts_menu.md (notas de cableado)
|
||||||
|
|
||||||
|
Globales:
|
||||||
|
- CHANGELOG.md (Added: app_menubar, layouts_menu)
|
||||||
|
- docs/adr/0002-menubar-arch.md (nuevo ADR — decision menubar reusable)
|
||||||
|
- dev/issues/README.md (issue 0027 → completado)
|
||||||
|
- dev/issues/completed/0027-...md (movido)
|
||||||
|
- .claude/rules/cpp_icons.md (regla nueva, anadida a INDEX.md)
|
||||||
|
|
||||||
|
Diario: docs/diary/2026-04-25.md (## 18:30 — ...)
|
||||||
|
|
||||||
|
Re-indexado: si | no
|
||||||
|
Pendientes registrados: {N}
|
||||||
|
Secretos omitidos: {lista de tipos redactados, ej. "REGISTRY_API_TOKEN"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist final
|
||||||
|
|
||||||
|
- [ ] Cada artefacto tocado tiene su `.md` actualizado (append, no overwrite).
|
||||||
|
- [ ] Ningun secreto, password, token o key en los archivos.
|
||||||
|
- [ ] Comandos exactos, IDs y paths para que otro LLM reproduzca.
|
||||||
|
- [ ] Decisiones con su "porque", bugs con su raiz+fix.
|
||||||
|
- [ ] `CHANGELOG.md` actualizado si el cambio es visible al usuario / agentes.
|
||||||
|
- [ ] ADR creado (`docs/adr/NNNN-*.md`) si hay decision arquitectural con alternativas descartadas.
|
||||||
|
- [ ] `dev/issues/*.md` y `dev/issues/README.md` actualizados si la sesion toco issues.
|
||||||
|
- [ ] `docs/{architecture,integrity,functions,types,fn_operations,execution_standard,sync_setup,init-pipelines,testing}.md` actualizado si el cambio afecta lo que cada uno documenta.
|
||||||
|
- [ ] `.claude/CLAUDE.md` raiz actualizado si cambian convenciones / comandos / schema globales.
|
||||||
|
- [ ] `.claude/rules/*` + `INDEX.md` actualizado si el usuario formaliza una regla nueva.
|
||||||
|
- [ ] Sub-CLAUDE de la app/analysis afectada actualizado si cambia comportamiento agente local.
|
||||||
|
- [ ] `/entrada_diario` invocado con resumen de la sesion.
|
||||||
|
- [ ] `./fn index` corrido si hubo creacion o cambio de frontmatter.
|
||||||
|
- [ ] Reporte final al usuario con la lista de archivos tocados (artefactos + globales).
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# /entrada_diario — Añadir entrada al diario del día
|
||||||
|
|
||||||
|
Añade una entrada nueva a `docs/diary/YYYY-MM-DD.md` con la fecha y hora actuales. Si el archivo del día no existe, lo crea con el encabezado del día. Si existe, **añade** una sección nueva al final (nunca sobrescribe ni reescribe entradas previas).
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```
|
||||||
|
/entrada_diario <descripción o resumen del bloque de trabajo>
|
||||||
|
```
|
||||||
|
|
||||||
|
Si no se pasa argumento, resume la sesión actual de forma concisa (qué hicimos, qué completamos, qué queda pendiente).
|
||||||
|
|
||||||
|
## Pasos que debe seguir el asistente
|
||||||
|
|
||||||
|
1. **Fecha y hora**:
|
||||||
|
```bash
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
TIME=$(date +%H:%M)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ruta del archivo del día**: `docs/diary/${DATE}.md`
|
||||||
|
|
||||||
|
3. **Si el archivo NO existe**, crearlo con:
|
||||||
|
```markdown
|
||||||
|
# ${DATE}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Componer la entrada** en este formato exacto:
|
||||||
|
```markdown
|
||||||
|
|
||||||
|
## ${TIME} — <título corto derivado del argumento>
|
||||||
|
|
||||||
|
<1-3 líneas de contexto breve si aplica>
|
||||||
|
|
||||||
|
- Hecho: <viñeta concreta>
|
||||||
|
- Hecho: <viñeta concreta>
|
||||||
|
- Pendiente: <viñeta si procede>
|
||||||
|
|
||||||
|
<Referencias opcionales: commit SHAs cortos, ADR #NNNN, issue #N, rutas a funciones del registry>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Añadir al final del archivo** (nunca editar secciones anteriores). Usar `Write` con el contenido completo si es el primer uso del día, `Edit` para append en días ya empezados.
|
||||||
|
|
||||||
|
## Reglas de estilo
|
||||||
|
|
||||||
|
- **Viñetas breves**, no párrafos. Si un punto necesita explicación larga, probablemente es un ADR en lugar de un diario.
|
||||||
|
- **Verbos en pasado para lo hecho**, infinitivo para lo pendiente.
|
||||||
|
- **Enlaces a artefactos**: commits (`SHA` corto de 7-8 chars), ADRs (`[0001](../adr/0001-...)`), funciones del registry por ID.
|
||||||
|
- **No duplicar con CHANGELOG**: el diario es contexto operativo ("qué hice hoy"), el CHANGELOG es "qué cambió cara al usuario".
|
||||||
|
- Si el argumento es vacío, revisar TaskList + cambios en git (`git log --since=today`, `git status`) y resumir en 3-5 viñetas.
|
||||||
|
|
||||||
|
## Ejemplos
|
||||||
|
|
||||||
|
```
|
||||||
|
/entrada_diario cerrado issue #23 del dashboard, fix en http_client.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
/entrada_diario # sin args → resume sesión
|
||||||
|
```
|
||||||
|
|
||||||
|
## Relación con otras formas de registro
|
||||||
|
|
||||||
|
| Si quieres documentar... | Usa |
|
||||||
|
|--------------------------|-----|
|
||||||
|
| Qué trabajé hoy | `/entrada_diario` → `docs/diary/` |
|
||||||
|
| Qué cambió en el código (cara usuario/agentes) | Editar `CHANGELOG.md` directamente |
|
||||||
|
| Por qué tomamos una decisión arquitectural | Nuevo ADR en `docs/adr/NNNN-*.md` |
|
||||||
|
| Una regla operativa nueva del registry | Nuevo archivo en `.claude/rules/` + entrada en INDEX.md |
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# /extract-design — Mejorar @fn_library con exports de Claude Design
|
||||||
|
|
||||||
|
Eres un agente mejorador del design system. Tu trabajo es analizar un export "standalone" de Claude Design (`sources/frontend_designs/*.html`), identificar componentes nuevos o mejoras sobre `@fn_library`, aplicarlos al registry y propagarlos al espejo público `subrepos/fn-design-system` (GitHub + Gitea).
|
||||||
|
|
||||||
|
**Objetivo:** cada diseño exportado debería dejar el registry un poco mejor que antes. Lo que Claude Design inventó para cubrir un hueco hoy → componente reutilizable del registry mañana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Argumento
|
||||||
|
|
||||||
|
`$ARGUMENTS` — ruta al `.html` en `sources/frontend_designs/`. Si no se proporciona:
|
||||||
|
1. Lista los `.html` bajo `sources/frontend_designs/` ordenados por fecha.
|
||||||
|
2. Muestra fecha + nombre + tamaño.
|
||||||
|
3. Pregunta cuál procesar. Default: el más reciente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 0 — Validar input
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lht sources/frontend_designs/*.html 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Si no existe el fichero, abortar. Si existe, leer las primeras líneas para confirmar que es un export de Claude Design (`__bundler/manifest`, `__bundler/template` en el HTML).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 1 — Decodificar el bundle
|
||||||
|
|
||||||
|
Ejecutar el extractor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 .claude/scripts/extract_design_bundle.py \
|
||||||
|
"sources/frontend_designs/<NOMBRE>.html" \
|
||||||
|
"sources/frontend_designs/<NOMBRE>_extracted/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Esperado: directorio con `app.jsx`, `fn_library_emu.jsx`, `charts_emu.jsx`, `data.jsx` + fuentes woff2 + `manifest.json`.
|
||||||
|
|
||||||
|
Si falta alguno de los 4 `.jsx` clave, inspeccionar por UUID; puede que Claude Design haya usado estructura distinta. Reportar al usuario.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 2 — Inventariar el diseño
|
||||||
|
|
||||||
|
Leer `app.jsx` y listar **todos los componentes React definidos** (funciones que empiezan con mayúscula o usan `function Xxx(`). Categorizar:
|
||||||
|
|
||||||
|
### 2a. Componentes del export que YA existen en `@fn_library`
|
||||||
|
- Grep el barrel: `cat frontend/functions/ui/index.ts | grep "^export"`.
|
||||||
|
- Para cada componente del export, ver si aparece en el barrel. Registrar coincidencias.
|
||||||
|
|
||||||
|
### 2b. Componentes nuevos (no existen en el registry)
|
||||||
|
Componentes React del `app.jsx` cuyo nombre no aparece en el barrel. Estos son **candidatos a extracción**.
|
||||||
|
|
||||||
|
### 2c. Uso de variantes / props no documentadas
|
||||||
|
Leer `fn_library_emu.jsx` del export y comparar API con tus `.tsx` reales:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Comprobar componentes específicos si el export los usa con props nuevas
|
||||||
|
sqlite3 registry.db "SELECT id, signature, props FROM functions WHERE id = 'alert_ts_ui';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Anotar discrepancias (variantes faltantes, props nuevas, tipos distintos).
|
||||||
|
|
||||||
|
### 2d. Datos/patrones reutilizables en `data.jsx`
|
||||||
|
- RNG determinista (mulberry32) → candidato a `frontend/functions/core/rng_seeded_ts_core` o `python/functions/core/`.
|
||||||
|
- Helpers tipo `statusBadge()` → documentar como receta, no como componente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 3 — Consultar el registry para evitar duplicados
|
||||||
|
|
||||||
|
Para cada componente candidato del paso 2b, búsqueda FTS5 antes de proponerlo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:<CANDIDATO>* OR description:<PALABRAS_CLAVE>') ORDER BY name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si encuentras algo similar que pueda ser mejorado en lugar de duplicado, márcalo como **mejora** a ese existente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 4 — Presentar el diagnóstico al usuario
|
||||||
|
|
||||||
|
Muestra en tablas separadas:
|
||||||
|
|
||||||
|
### 🟢 Componentes nuevos candidatos
|
||||||
|
|
||||||
|
| # | Nombre propuesto | Dominio | Líneas | Reutilizable en | API |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `funnel_chart_ts_ui` | ui | ~35 | CRM, analytics, funnels genéricos | `(data: Array<{stage, value}>, variant?) → JSX` |
|
||||||
|
|
||||||
|
### 🟡 Mejoras a componentes existentes
|
||||||
|
|
||||||
|
| # | Componente | Mejora | Tipo | Riesgo |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | `alert_ts_ui` | Añadir variantes `success`, `warning`, `info` | Expandir enum | Bajo — no rompe API |
|
||||||
|
| B | `data_table_ts_ui` | Prop `density: 'compact'|'cozy'|'roomy'` | Añadir prop opcional | Bajo |
|
||||||
|
|
||||||
|
### 🔵 Patrones a documentar (no componente)
|
||||||
|
|
||||||
|
| Patrón | Dónde registrar |
|
||||||
|
|---|---|
|
||||||
|
| `statusBadge` helper | `DESIGN_SYSTEM.md` sección "patterns" |
|
||||||
|
|
||||||
|
**Esperar confirmación.** El usuario responde con sintaxis `1,2,A,B` (o `all`, o `nuevos only`, o descarta algunos). Si dice `all`, aplica todo lo listado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 5 — Aplicar mejoras aprobadas
|
||||||
|
|
||||||
|
### 5a. Para componentes nuevos (candidatos 🟢)
|
||||||
|
|
||||||
|
Por cada aprobado:
|
||||||
|
|
||||||
|
1. **Leer código** del `app.jsx` / `fn_library_emu.jsx` / `charts_emu.jsx` del export.
|
||||||
|
2. **Adaptar al stack real del registry:**
|
||||||
|
- Cambiar elementos SVG/HTML planos por primitivas de `@mantine/core` cuando corresponda (`Paper`, `Stack`, `Group`, `Text`).
|
||||||
|
- Cambiar `style={{...}}` por props Mantine (`p`, `m`, `fw`, `gap`, `radius`, `c`).
|
||||||
|
- Si es un chart, delegar en `@mantine/charts` cuando sea posible; solo usar SVG puro si Mantine no cubre el caso (ej: `Sparkline` en el registry ya es SVG puro por rendimiento).
|
||||||
|
- Iconos: `@tabler/icons-react`.
|
||||||
|
3. **Crear los dos ficheros** siguiendo la convención:
|
||||||
|
- `frontend/functions/ui/<name>.tsx` — código React.
|
||||||
|
- `frontend/functions/ui/<name>.md` — frontmatter completo.
|
||||||
|
4. **Frontmatter del .md** (campos clave):
|
||||||
|
```yaml
|
||||||
|
id: <name>_ts_ui
|
||||||
|
name: <name>
|
||||||
|
kind: component
|
||||||
|
lang: ts
|
||||||
|
domain: ui
|
||||||
|
purity: impure
|
||||||
|
framework: react
|
||||||
|
version: 1.0.0
|
||||||
|
description: "..."
|
||||||
|
tags: [...]
|
||||||
|
props: {...}
|
||||||
|
emits: null
|
||||||
|
params: []
|
||||||
|
output: "JSX.Element — ..."
|
||||||
|
source_repo: "claude.ai/design"
|
||||||
|
source_license: ""
|
||||||
|
source_file: "sources/frontend_designs/<NOMBRE>.html"
|
||||||
|
file_path: frontend/functions/ui/<name>.tsx
|
||||||
|
tested: false
|
||||||
|
```
|
||||||
|
5. **Añadir al barrel** `frontend/functions/ui/index.ts`: `export { Xxx } from './<name>'`.
|
||||||
|
|
||||||
|
### 5b. Para mejoras a componentes existentes (🟡)
|
||||||
|
|
||||||
|
Por cada aprobada:
|
||||||
|
|
||||||
|
1. **Leer** el `.tsx` actual.
|
||||||
|
2. **Aplicar la mejora** sin romper la API existente: añade prop opcional, amplía enum de `variant`, etc.
|
||||||
|
3. **Actualizar** el `.md` correspondiente para reflejar las nuevas variantes/props (campos `variant`, `props`, `description`).
|
||||||
|
4. **Si la firma cambia**, actualizar también el `signature` del frontmatter.
|
||||||
|
|
||||||
|
### 5c. Para patrones a documentar (🔵)
|
||||||
|
|
||||||
|
1. Añadir una sección "Patterns" en `frontend/DESIGN_SYSTEM.md` si no existe.
|
||||||
|
2. Registrar el patrón con un ejemplo corto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 6 — Indexar y verificar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn index
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si falla por integridad, arreglar y reintentar.
|
||||||
|
- Verificar cada componente nuevo: `./fn show <id>`.
|
||||||
|
- Confirmar que el barrel compila haciendo `cd frontend && pnpm tsc --noEmit` (si tarda, al menos verificar imports manualmente).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 7 — Sincronizar al espejo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd subrepos/fn-design-system
|
||||||
|
./sync_from_registry.sh
|
||||||
|
git add -A
|
||||||
|
git status --short # Mostrar qué cambió en el espejo
|
||||||
|
```
|
||||||
|
|
||||||
|
Si hay cambios, preparar commit. Si no, el sync no recogió las modificaciones — investigar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 8 — Commit en ambos repos
|
||||||
|
|
||||||
|
### 8a. Commit en `fn_registry`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/functions/ui/ frontend/DESIGN_SYSTEM.md registry.db
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(ui): extract <N> components / <M> improvements from design export
|
||||||
|
|
||||||
|
From: sources/frontend_designs/<NOMBRE>.html
|
||||||
|
|
||||||
|
New components:
|
||||||
|
- <id> — <descripción corta>
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Improvements:
|
||||||
|
- <id> — <cambio>
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8b. Commit en el espejo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd subrepos/fn-design-system
|
||||||
|
git commit -m "sync: <N> new components + <M> improvements from <NOMBRE>
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 9 — Push
|
||||||
|
|
||||||
|
### 9a. Push del espejo (ambos remotes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd subrepos/fn-design-system
|
||||||
|
./push_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto propaga a:
|
||||||
|
- `gitea/dataforge/fn-design-system`
|
||||||
|
- `github/gutierenmanuel/fn-design-system` ← este es el que Claude Design consume
|
||||||
|
|
||||||
|
### 9b. Push de fn_registry
|
||||||
|
|
||||||
|
**Preguntar al usuario** antes — no push sin permiso (ver CLAUDE.md del proyecto). Si dice sí:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 10 — Resumen final
|
||||||
|
|
||||||
|
Mostrar al usuario:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Extracción completa.
|
||||||
|
|
||||||
|
Nuevos componentes en @fn_library:
|
||||||
|
- <id> (frontend/functions/ui/<name>.tsx)
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Mejoras aplicadas:
|
||||||
|
- <id>: <qué cambió>
|
||||||
|
|
||||||
|
Espejo actualizado:
|
||||||
|
- Commit gitea: <sha> → <url>
|
||||||
|
- Commit github: <sha> → <url>
|
||||||
|
|
||||||
|
Claude Design verá estas mejoras en su próxima lectura del repo enlazado.
|
||||||
|
Siguiente acción sugerida: probar un prompt de dashboard que use <componente_nuevo>.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas críticas
|
||||||
|
|
||||||
|
- **NUNCA extraer sin aprobación explícita del usuario** — siempre paso 4 con tabla y espera.
|
||||||
|
- **NUNCA sobrescribir un componente existente** en el paso 5b — solo añadir variantes/props opcionales. Si la mejora es incompatible, proponerlo como propuesta aparte (`fn proposal add`) en vez de aplicarla.
|
||||||
|
- **SIEMPRE `source_repo: "claude.ai/design"`** en el frontmatter de componentes nuevos, y `source_file` apuntando al `.html` original.
|
||||||
|
- **SIEMPRE mantener el orden:** registry → index → verify → sync mirror → commit both → push mirror → (ask to push fn_registry).
|
||||||
|
- **El barrel `index.ts`** debe estar actualizado antes de hacer `fn index` (hay apps que lo importan).
|
||||||
|
- **NO committear** `operations.db*`, `node_modules/`, `dist/`, `.env` ni nada que `.gitignore` excluya. Usa `git add` con rutas explícitas, no `git add -A` a ciegas.
|
||||||
|
- **Si el usuario cancela a mitad**, dejar el working tree limpio o documentar qué quedó pendiente. No medio-commits.
|
||||||
|
- **Patrones que no tienen sentido como primitiva** (ej. envs, branding específico) → documentar, no componentizar.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# /full-git-pull — Pull de fn_registry + todos los sub-repos + submodules + fn sync
|
||||||
|
|
||||||
|
Trae los últimos cambios del remote para el repo principal `fn_registry`, todos los sub-repos git anidados, y los submodules de `cpp/vendor/`. Después regenera `registry.db` y corre `fn sync` para tirar de la metadata del `registry_api` (apps, projects, analysis, vaults, pc_locations registrados desde otros PCs).
|
||||||
|
|
||||||
|
## Argumento
|
||||||
|
|
||||||
|
`$ARGUMENTS` — sin uso, ignorar.
|
||||||
|
|
||||||
|
## Pasos
|
||||||
|
|
||||||
|
### 1. Descubrir repos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/egutierrez/fn_registry
|
||||||
|
REPOS=$(find . -name ".git" -type d \
|
||||||
|
-not -path "./.git/*" \
|
||||||
|
-not -path "*/node_modules/*" \
|
||||||
|
-not -path "*/.venv/*" \
|
||||||
|
-not -path "*/cpp/vendor/*" \
|
||||||
|
-not -path "*/cpp/build/*" \
|
||||||
|
-not -path "*/sources/*" \
|
||||||
|
-not -path "*/temp/*" 2>/dev/null | sed 's|/.git$||')
|
||||||
|
REPOS=". $REPOS"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Para cada repo: stash si dirty, pull --ff-only, pop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for r in $REPOS; do
|
||||||
|
( cd "$r" \
|
||||||
|
&& DIRTY=$(git status --porcelain | wc -l) \
|
||||||
|
&& if [ "$DIRTY" -gt 0 ]; then
|
||||||
|
git stash push -m "auto-stash before /full-git-pull" --include-untracked >/dev/null
|
||||||
|
STASHED=1
|
||||||
|
else
|
||||||
|
STASHED=0
|
||||||
|
fi \
|
||||||
|
&& git fetch origin 2>&1 | tail -1 \
|
||||||
|
&& git pull --ff-only 2>&1 | tail -3 \
|
||||||
|
&& if [ "$STASHED" = "1" ]; then
|
||||||
|
git stash pop 2>&1 | tail -3
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si `--ff-only` falla por divergencia, abortar el pull de ese repo y reportar (no rebasear sin permiso).
|
||||||
|
- Si `stash pop` produce conflictos, **avisar** y dejar el conflicto al usuario; no resolverlo automáticamente.
|
||||||
|
|
||||||
|
### 3. Submodules del repo principal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Regenerar registry.db local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 ./fn index 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. fn sync con credenciales de pass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
USER=$(pass registry/basicauth-user | head -1)
|
||||||
|
PASSWD=$(pass registry/basicauth-pass | head -1)
|
||||||
|
TOKEN=$(pass registry/api-token | head -1)
|
||||||
|
export FN_REGISTRY_API="https://${USER}:${PASSWD}@registry.organic-machine.com"
|
||||||
|
export REGISTRY_API_TOKEN="$TOKEN"
|
||||||
|
./fn sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `pass` falla → gpg-agent locked, pedir al usuario `pass show registry/api-token` en su terminal real.
|
||||||
|
|
||||||
|
### 6. Resumen
|
||||||
|
|
||||||
|
Tabla concisa: por repo, commits pulleados o "ya estaba al día"; submodules actualizados; result de `fn index`; result de `fn sync`.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Pull solo es fast-forward — nunca rebase ni merge automático.
|
||||||
|
- Si el repo principal pulleó cambios y eliminó archivos referenciados por sub-repos (raro), el usuario debe resolverlo manualmente.
|
||||||
|
- `fn index` se corre **antes** de `fn sync` para que las locations locales reflejen el estado actual.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# /full-git-push — Push de fn_registry + todos los sub-repos + fn sync
|
||||||
|
|
||||||
|
Pushea el repo principal `fn_registry` y todos los sub-repos git anidados (apps externalizadas como `registry_dashboard`, projects con repo propio, etc.), y luego ejecuta `fn sync` para empujar la metadata no regenerable (proposals, apps, projects, analysis, vaults, pc_locations) al `registry_api`.
|
||||||
|
|
||||||
|
## Argumento
|
||||||
|
|
||||||
|
`$ARGUMENTS` — opcional. Si se pasa texto, se usa como mensaje de commit por defecto cuando algún repo tenga cambios sin commitear y el usuario apruebe commitear durante el flujo. Sin argumento, se pregunta el mensaje al detectar dirty tree.
|
||||||
|
|
||||||
|
## Pasos
|
||||||
|
|
||||||
|
### 1. Descubrir repos git en el workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/egutierrez/fn_registry
|
||||||
|
REPOS=$(find . -name ".git" -type d \
|
||||||
|
-not -path "./.git/*" \
|
||||||
|
-not -path "*/node_modules/*" \
|
||||||
|
-not -path "*/.venv/*" \
|
||||||
|
-not -path "*/cpp/vendor/*" \
|
||||||
|
-not -path "*/cpp/build/*" \
|
||||||
|
-not -path "*/sources/*" \
|
||||||
|
-not -path "*/temp/*" 2>/dev/null | sed 's|/.git$||')
|
||||||
|
# Añadir la raíz al principio
|
||||||
|
REPOS=". $REPOS"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Para cada repo, mostrar estado
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for r in $REPOS; do
|
||||||
|
echo "=== $r ==="
|
||||||
|
( cd "$r" && git status -sb && echo "" )
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Manejar dirty trees
|
||||||
|
|
||||||
|
- Si **algún repo** tiene cambios sin commitear: lista los archivos al usuario y **pregunta** qué hacer:
|
||||||
|
- (a) commitear todo con un mensaje (usar `$ARGUMENTS` si está, si no preguntar)
|
||||||
|
- (b) stashear y seguir solo con los commits ahead
|
||||||
|
- (c) abortar
|
||||||
|
- Nunca commitear sin permiso explícito.
|
||||||
|
|
||||||
|
### 4. Push de cada repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for r in $REPOS; do
|
||||||
|
( cd "$r" \
|
||||||
|
&& BRANCH=$(git rev-parse --abbrev-ref HEAD) \
|
||||||
|
&& if git rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1; then
|
||||||
|
git push origin "$BRANCH" 2>&1 | tail -3
|
||||||
|
else
|
||||||
|
echo "[$r] no upstream para '$BRANCH' — saltado"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. fn sync con credenciales de pass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
USER=$(pass registry/basicauth-user | head -1)
|
||||||
|
PASSWD=$(pass registry/basicauth-pass | head -1)
|
||||||
|
TOKEN=$(pass registry/api-token | head -1)
|
||||||
|
export FN_REGISTRY_API="https://${USER}:${PASSWD}@registry.organic-machine.com"
|
||||||
|
export REGISTRY_API_TOKEN="$TOKEN"
|
||||||
|
./fn sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `pass` falla con "decryption failed" → gpg-agent locked. Pedir al usuario que ejecute `pass show registry/api-token` en su terminal real (Bash tool no tiene TTY) y reintentar.
|
||||||
|
|
||||||
|
### 6. Resumen
|
||||||
|
|
||||||
|
Imprimir tabla concisa: para cada repo, branch, commits pusheados o "ya estaba al día". Y resultado de `fn sync` (sent / received / imported).
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Es responsabilidad del comando **pushear**, no decidir qué commitear. Solo commitea si el usuario lo aprueba explícitamente.
|
||||||
|
- Los submodules del directorio `cpp/vendor/` (imgui, implot, glfw, tracy, implot3d) se ignoran (son mirrors upstream, no se pushean desde aquí).
|
||||||
|
- Si una rama va `behind` el remote, abortar el push de ese repo y avisar para correr `/full-git-pull` primero.
|
||||||
@@ -109,6 +109,125 @@ metabase_update_dashboard(client, dash["id"], dashcards=[
|
|||||||
|
|
||||||
**Filtros de list_dashboards:** `all`, `mine`, `archived`
|
**Filtros de list_dashboards:** `all`, `mine`, `archived`
|
||||||
|
|
||||||
|
### Documents (ProseMirror)
|
||||||
|
|
||||||
|
Los "documents" son páginas narrativas editables con texto rico y cards embebidas. **No hay helpers en fn_registry todavía** — usa el endpoint REST directamente a través de `client._http`.
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
|
||||||
|
| Método | Ruta | Qué hace |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/document` | Lista documents (`{items: [...]}`) |
|
||||||
|
| GET | `/api/document/{id}` | Lee un document (incluye `document` con árbol ProseMirror) |
|
||||||
|
| POST | `/api/document` | Crea. Payload: `{name, collection_id, document}` |
|
||||||
|
| PUT | `/api/document/{id}` | Actualiza. Mismo payload que POST |
|
||||||
|
| PUT | `/api/document/{id}` con `{archived: true}` | Soft-delete |
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Crear documento
|
||||||
|
resp = client._http.request("POST", "/api/document", json={
|
||||||
|
"name": "Mi análisis",
|
||||||
|
"collection_id": 583, # obligatorio — raíz no se acepta desde API
|
||||||
|
"document": {"type": "doc", "content": [
|
||||||
|
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "Título"}]},
|
||||||
|
{"type": "paragraph", "content": [{"type": "text", "text": "Cuerpo."}]},
|
||||||
|
]},
|
||||||
|
})
|
||||||
|
doc_id = resp.json()["id"]
|
||||||
|
print(f"https://reports.autingo.es/document/{doc_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tipos de nodo SOPORTADOS en Metabase v0.59.x
|
||||||
|
|
||||||
|
Solo estos tipos renderizan. **Cualquier tipo fuera de esta lista hace que el documento se vea vacío al abrirlo.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
ALLOWED_DOC_NODES = {
|
||||||
|
"doc", "heading", "paragraph", "text",
|
||||||
|
"horizontalRule", "blockquote",
|
||||||
|
"bulletList", "listItem",
|
||||||
|
"codeBlock", # attrs.language ej: "sql"
|
||||||
|
"resizeNode", # envuelve SIEMPRE a cardEmbed
|
||||||
|
"cardEmbed", # solo dentro de resizeNode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Marcas inline válidas en nodos `text`: `bold`, `italic`, `code`, `strike` (se aplican con `"marks": [{"type": "bold"}, ...]`).
|
||||||
|
|
||||||
|
#### Tipos PROHIBIDOS (rompen el render)
|
||||||
|
|
||||||
|
- `table`, `tableRow`, `tableHeader`, `tableCell` → en v0.59.x no están registrados en el schema del editor y el doc entero se vuelve invisible.
|
||||||
|
- `callout` → idem (documentado en memoria `feedback_metabase_prosemirror.md`).
|
||||||
|
- `image`, `video`, `iframe`, `mention`, cualquier embed de terceros → no registrados.
|
||||||
|
|
||||||
|
Si necesitas una tabla, **emúlala con una `bulletList` de `**clave:** valor`**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def kv_list(pairs):
|
||||||
|
return {"type": "bulletList", "content": [
|
||||||
|
{"type": "listItem", "content": [
|
||||||
|
{"type": "paragraph", "content": [
|
||||||
|
{"type": "text", "text": k, "marks": [{"type": "bold"}]},
|
||||||
|
{"type": "text", "text": f": {v}"},
|
||||||
|
]},
|
||||||
|
]}
|
||||||
|
for k, v in pairs
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### cardEmbed SIEMPRE dentro de resizeNode
|
||||||
|
|
||||||
|
Un `cardEmbed` suelto no renderiza. Patrón obligatorio:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def card_embed(card_id, height=420):
|
||||||
|
import uuid
|
||||||
|
return {
|
||||||
|
"type": "resizeNode",
|
||||||
|
"attrs": {"height": height, "minHeight": 280},
|
||||||
|
"content": [{
|
||||||
|
"type": "cardEmbed",
|
||||||
|
"attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Validación OBLIGATORIA antes de POST/PUT
|
||||||
|
|
||||||
|
Nunca envíes un document a Metabase sin validar primero. Un solo nodo prohibido lo deja invisible sin devolver error HTTP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ALLOWED = {"doc","heading","paragraph","text","horizontalRule","blockquote",
|
||||||
|
"bulletList","listItem","codeBlock","resizeNode","cardEmbed"}
|
||||||
|
|
||||||
|
def validate_doc(node, path=""):
|
||||||
|
errs = []
|
||||||
|
if isinstance(node, dict):
|
||||||
|
typ = node.get("type", "?")
|
||||||
|
if typ not in ALLOWED:
|
||||||
|
errs.append(f"{path}: tipo no permitido '{typ}'")
|
||||||
|
if typ == "resizeNode":
|
||||||
|
inner = node.get("content", [])
|
||||||
|
if not (len(inner) == 1 and inner[0].get("type") == "cardEmbed"):
|
||||||
|
errs.append(f"{path}: resizeNode debe contener exactamente un cardEmbed")
|
||||||
|
return errs # no re-descender al cardEmbed interno
|
||||||
|
for i, c in enumerate(node.get("content", []) or []):
|
||||||
|
errs += validate_doc(c, f"{path}/{typ}[{i}]")
|
||||||
|
return errs
|
||||||
|
|
||||||
|
errs = validate_doc(my_doc)
|
||||||
|
assert not errs, f"Doc inválido:\n" + "\n".join(f" - {e}" for e in errs)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Aprender estructura de un doc que ya funciona
|
||||||
|
|
||||||
|
Si dudas sobre un nodo, **clónalo de un doc existente que renderice**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
d = client._http.request("GET", "/api/document/2").json()
|
||||||
|
# d["document"] contiene el árbol completo en ProseMirror
|
||||||
|
```
|
||||||
|
|
||||||
### Databases
|
### Databases
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -17,3 +17,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
| 11 | [sources.md](sources.md) | Extraccion de funciones desde repos externos |
|
||||||
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
| 12 | [notebook_collaboration.md](notebook_collaboration.md) | Colaboración en notebooks Jupyter via funciones del registry |
|
||||||
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
|
| 13 | [frontend_theming.md](frontend_theming.md) | Componentes propios y sistema de temas en frontends |
|
||||||
|
| 14 | [deploy.md](deploy.md) | Deploy de apps a VPS remotos via SSH + systemd + rsync |
|
||||||
|
| 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema |
|
||||||
|
| 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas |
|
||||||
|
| 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) |
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## Trunk-based development (TBD) en apps generadas con `fn`
|
||||||
|
|
||||||
|
**El registry NO usa TBD** (push directo a master OK). Pero **toda app generada con `fn`** que viva en `apps/`, `projects/<name>/apps/` o que se despliegue a un VPS via `deploy_server` **DEBE seguir TBD** mientras se desarrolla:
|
||||||
|
|
||||||
|
```
|
||||||
|
master ← siempre deployable
|
||||||
|
↑
|
||||||
|
└── issue/<NNNN>-<slug> ← rama efimera (horas)
|
||||||
|
└── quick/<slug> ← cambios rapidos sin issue
|
||||||
|
commits atomicos (feat:, fix:, test:, docs:, refactor:, chore:)
|
||||||
|
merge --no-ff → master → push → delete branch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
1. **Nunca trabajar directo en master para una app**. Crear `issue/<NNNN>-<slug>` o `quick/<slug>` primero.
|
||||||
|
2. **Commits atomicos** por bloque logico (no WIP, no mezclar tipos).
|
||||||
|
3. **Tests obligatorios** antes de mergear (los que aplique al stack: ctest/go test/pytest/...).
|
||||||
|
4. **`merge --no-ff`** preserva la historia paralela. `git log --first-parent master` da la vista limpia.
|
||||||
|
5. **Feature flags** (no WIP) cuando una feature no cabe en una sola rama. Archivo: `dev/feature_flags.json`.
|
||||||
|
|
||||||
|
### Por que el registry esta exento
|
||||||
|
|
||||||
|
El registry es un repo de funciones reutilizables, no un servicio en produccion. Los cambios son atomicos por su propia naturaleza (una funcion = uno o dos archivos). Imponer TBD a cada `fn add` añadiria fricion sin ganancia: la BD se regenera con `fn index`, no hay deployment, no hay usuarios consumiendo master en directo.
|
||||||
|
|
||||||
|
### Cuando aplica TBD
|
||||||
|
|
||||||
|
| Cambio | TBD obligatorio |
|
||||||
|
|---|---|
|
||||||
|
| Funcion nueva en `cpp/functions/`, `python/functions/`, etc. | NO — push directo a master |
|
||||||
|
| Tipo nuevo en `types/` | NO |
|
||||||
|
| Doc/regla en `.claude/`, `docs/` | NO |
|
||||||
|
| Issue del registry mismo (`dev/issues/`) | NO — issue cerrado y push directo |
|
||||||
|
| App nueva o modificacion de app en `apps/` o `projects/*/apps/` | **SI** |
|
||||||
|
| Service desplegable (`tag: service`) | **SI** |
|
||||||
|
| Analysis en `analysis/` o `projects/*/analysis/` | NO — son exploraciones efimeras |
|
||||||
|
|
||||||
|
### Comandos
|
||||||
|
|
||||||
|
- `/git-branch` — crea rama desde master actualizado (para apps).
|
||||||
|
- `/git-push` — tests → merge `--no-ff` → push → eliminar rama (para apps).
|
||||||
|
|
||||||
|
Para el registry, push directo a master con commits atomicos.
|
||||||
@@ -7,3 +7,12 @@ Criterios para decidir:
|
|||||||
- **apps/**: orquesta funciones del registry para un caso concreto, tiene config/credenciales, layout fijo
|
- **apps/**: orquesta funciones del registry para un caso concreto, tiene config/credenciales, layout fijo
|
||||||
|
|
||||||
Las apps Python importan funciones del registry con: `sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))` y luego `from <paquete> import ...` (sin prefijo `functions.`).
|
Las apps Python importan funciones del registry con: `sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))` y luego `from <paquete> import ...` (sin prefijo `functions.`).
|
||||||
|
|
||||||
|
## temp/ — workspace efimero
|
||||||
|
|
||||||
|
`temp/` es un espacio de trabajo desechable para pruebas rapidas: probar una API, un script exploratorio, un analisis puntual, prototipos. Todo gitignored.
|
||||||
|
|
||||||
|
- **NO es codigo del registry** — nada en `temp/` se indexa ni se versiona
|
||||||
|
- **Estructura libre** — subcarpetas por tema: `temp/api_test/`, `temp/quick_analysis/`, etc.
|
||||||
|
- **Extraccion**: si algo en `temp/` resulta util, se extrae al registry con el flujo normal (como si fuera `sources/`)
|
||||||
|
- **Limpieza**: se puede borrar el contenido en cualquier momento sin consecuencias
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
## Deploy de apps a VPS remotos
|
||||||
|
|
||||||
|
### Arquitectura
|
||||||
|
|
||||||
|
El sistema de deploy usa SSH + systemd + rsync. No Docker, no Kubernetes.
|
||||||
|
|
||||||
|
- **Conexiones SSH** → `~/.ssh/config` (alias, IP, user, key). Ya hay funciones CRUD: `ssh_config_read`, `ssh_config_find`, `ssh_config_parse`.
|
||||||
|
- **Config de deploy** → `apps/deploy_server/operations.db` tabla `deploy_targets` (app, host, remote_dir, build_cmd, port, health_path, env).
|
||||||
|
- **Logs de deploy** → misma BD, tabla `deploy_logs` (app, host, status, trigger, duration_ms, error).
|
||||||
|
|
||||||
|
### App: `deploy_server` (`apps/deploy_server/`)
|
||||||
|
|
||||||
|
CLI + servidor HTTP. Binario: `deploy_server`. Build: `CGO_ENABLED=1 go build -o deploy_server .`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/deploy_server
|
||||||
|
|
||||||
|
# Gestionar targets
|
||||||
|
./deploy_server target add --app <app> --host <ssh_alias> --port <N> --health /path --build "comando" [--user deploy] [--env '{"K":"V"}']
|
||||||
|
./deploy_server target list
|
||||||
|
./deploy_server target remove <app>
|
||||||
|
|
||||||
|
# Setup inicial (primera vez, crea dirs + systemd unit)
|
||||||
|
./deploy_server setup <app> --host <ssh_alias>
|
||||||
|
|
||||||
|
# Deploy continuo (build local → rsync → restart → health check)
|
||||||
|
./deploy_server deploy <app> [--host <ssh_alias>]
|
||||||
|
|
||||||
|
# Estado del servicio remoto
|
||||||
|
./deploy_server status <app>
|
||||||
|
./deploy_server status --all
|
||||||
|
|
||||||
|
# Servidor webhook (auto-deploy en cada push a Gitea)
|
||||||
|
./deploy_server serve --port 9090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones del registry involucradas
|
||||||
|
|
||||||
|
| Función | Qué hace | Purity |
|
||||||
|
|---|---|---|
|
||||||
|
| `rsync_deploy_bash_infra` | rsync local→remoto con exclusiones | impure |
|
||||||
|
| `systemd_generate_unit_go_infra` | Genera texto .service | **pure** |
|
||||||
|
| `systemd_install_go_infra` | Sube unit + daemon-reload + enable + start | impure |
|
||||||
|
| `systemd_restart_go_infra` | Reinicia servicio remoto | impure |
|
||||||
|
| `systemd_status_go_infra` | Estado + logs de servicio remoto | impure |
|
||||||
|
| `vps_setup_app_go_infra` | Crea dirs + usuario en VPS | impure |
|
||||||
|
| `gitea_create_webhook_bash_infra` | Crea webhook push en Gitea | impure |
|
||||||
|
| `setup_vps_app_go_infra` | Pipeline: setup completo primera vez | impure |
|
||||||
|
| `deploy_app_remote_go_infra` | Pipeline: deploy continuo | impure |
|
||||||
|
|
||||||
|
Tipo: `DeployConfig_go_infra` — struct con toda la config de deploy.
|
||||||
|
|
||||||
|
### Workflow para un agente
|
||||||
|
|
||||||
|
Cuando el usuario diga **"sube esta app a este VPS"** o **"deploya X en Y"**:
|
||||||
|
|
||||||
|
#### 1. Verificar que el host SSH existe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "^Host " ~/.ssh/config
|
||||||
|
# Si no existe el alias, añadirlo:
|
||||||
|
# Usar ssh_config_add_entry o editar ~/.ssh/config directamente
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Verificar conectividad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=5 <alias> true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Registrar el target en deploy_server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/deploy_server
|
||||||
|
# Build deploy_server si no existe el binario
|
||||||
|
CGO_ENABLED=1 go build -o deploy_server .
|
||||||
|
|
||||||
|
./deploy_server target add \
|
||||||
|
--app <nombre_app> \
|
||||||
|
--host <ssh_alias> \
|
||||||
|
--port <puerto> \
|
||||||
|
--health <path_o_vacio> \
|
||||||
|
--build "CGO_ENABLED=0 GOOS=linux go build -o <binario> ." \
|
||||||
|
--user deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Setup inicial
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy_server setup <app> --host <ssh_alias>
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea dirs en `/opt/apps/<app>/`, sube el código, genera el unit systemd e instala el servicio.
|
||||||
|
|
||||||
|
#### 5. Deploys posteriores
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy_server deploy <app>
|
||||||
|
```
|
||||||
|
|
||||||
|
Build local → rsync → restart systemd → health check.
|
||||||
|
|
||||||
|
#### 6. Auto-deploy con webhook (opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lanzar servidor
|
||||||
|
./deploy_server serve --port 9090
|
||||||
|
|
||||||
|
# Crear webhook en Gitea
|
||||||
|
source bash/functions/infra/gitea_create_webhook.sh
|
||||||
|
gitea_create_webhook "<owner>" "<repo>" "http://<ip_deploy_server>:9090/webhook/push" "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requisitos en el VPS
|
||||||
|
|
||||||
|
- SSH accesible con key auth (configurado en `~/.ssh/config` local)
|
||||||
|
- El usuario SSH debe tener **sudo sin password** para: `systemctl`, `mv` a `/etc/systemd/system/`, `mkdir` en `/opt/apps/`, `useradd`, `chown`
|
||||||
|
- `rsync` instalado en el VPS
|
||||||
|
- Puerto del servicio abierto en el firewall del VPS
|
||||||
|
|
||||||
|
### Builds por lenguaje
|
||||||
|
|
||||||
|
| Lenguaje | Build command típico |
|
||||||
|
|---|---|
|
||||||
|
| Go | `CGO_ENABLED=0 GOOS=linux go build -o <nombre> .` |
|
||||||
|
| Go + SQLite | `CGO_ENABLED=1 GOOS=linux go build -tags fts5 -o <nombre> .` |
|
||||||
|
| Python | No build — rsync sube los .py, systemd ejecuta `python3 main.py` |
|
||||||
|
| Bash | No build — rsync sube los .sh, systemd ejecuta `bash main.sh` |
|
||||||
|
|
||||||
|
Para Go con CGO (SQLite), el VPS debe tener `gcc` y `libc-dev`, o cross-compilar con `CGO_ENABLED=0` si la app no usa SQLite.
|
||||||
|
|
||||||
|
### Exclusiones de rsync
|
||||||
|
|
||||||
|
El deploy excluye automáticamente: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`, `registry.db`.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## KISS en proyectos y apps
|
||||||
|
|
||||||
|
**Mantener proyectos (`projects/`) y apps (`apps/`, `projects/*/apps/`) simples**. La complejidad no solicitada es deuda — cada línea, cada dependencia y cada herramienta externa se justifican o no entran.
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
1. **Preferir herramientas ya presentes en el sistema o en el registry** antes que paquetes/CLI externos.
|
||||||
|
- ¿Lo hace `git` / `bash` / una función del registry? Úsalo.
|
||||||
|
- Antes de añadir una dependencia nueva, buscar en `registry.db` (FTS5) si ya existe algo similar.
|
||||||
|
|
||||||
|
2. **Cuestionar cada nueva herramienta externa**. Antes de instalarla preguntar:
|
||||||
|
- ¿Qué problema concreto resuelve que NO podemos resolver con lo que ya tenemos?
|
||||||
|
- ¿El coste (instalar, mantener, aprender, conflictos con nuestro flujo) compensa el beneficio real?
|
||||||
|
- ¿Qué pasa si el proyecto upstream se abandona / rompe compatibilidad?
|
||||||
|
|
||||||
|
3. **Sin abstracciones ni features especulativas**. No generalizar "por si acaso". Tres líneas similares son mejores que una abstracción prematura.
|
||||||
|
|
||||||
|
4. **Ser consciente del flujo de trabajo actual**. Si algo funciona bien con `git` / submódulos / `fn` CLI, no lo sustituyas por una herramienta que prometa "mejorarlo" sin evidencia de mejora concreta en tu contexto.
|
||||||
|
|
||||||
|
5. **Escritura de apps**: una responsabilidad clara, layout mínimo (`main.*`, `app.md`, y lo estrictamente necesario), sin config ni estructuras que no se usen hoy.
|
||||||
|
|
||||||
|
### Caso aprendido (GitButler)
|
||||||
|
|
||||||
|
Se probó GitButler (virtual branches) pensando en paralelizar trabajo. Resultado:
|
||||||
|
- Bugs con submódulos (git submodule add + gitlinks) — commits vacíos o contenido cruzado.
|
||||||
|
- Auto-commits con el texto del chat como commit message.
|
||||||
|
- Pre-commit hook que bloquea `git commit` directo y exige otro CLI (`but`).
|
||||||
|
- Un binario externo de 37 MB + un plugin en Claude Code + skill propio + hooks en `settings.json`.
|
||||||
|
|
||||||
|
Al volver a `git` + ramas normales + `fn` CLI: cero fricción, commits limpios, submódulos funcionan. **Lección**: antes de adoptar una capa nueva, medir la fricción real actual. Si no la hay, no vale la pena añadir complejidad.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
## Projects: apps, analysis y vaults bajo un tema comun
|
||||||
|
|
||||||
|
Un project agrupa apps, analyses y vaults relacionados. Vive en `projects/{nombre}/` con esta estructura:
|
||||||
|
|
||||||
|
```
|
||||||
|
projects/{nombre}/
|
||||||
|
project.md # Frontmatter obligatorio (name, description, tags)
|
||||||
|
apps/ # Apps del proyecto (cada una con app.md)
|
||||||
|
{app_name}/
|
||||||
|
app.md
|
||||||
|
...
|
||||||
|
analysis/ # Analyses del proyecto (cada uno con analysis.md)
|
||||||
|
{analysis_name}/
|
||||||
|
analysis.md
|
||||||
|
.venv/
|
||||||
|
notebooks/
|
||||||
|
run-jupyter-lab.sh
|
||||||
|
...
|
||||||
|
vaults/ # Datos del proyecto
|
||||||
|
vault.yaml # Manifest de vaults (nombre, descripcion, path, tags)
|
||||||
|
{vault_name} -> /abs/path # Symlinks a directorios reales de datos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
- `project.md` sigue el template de `docs/templates/project.md` — campos: `name`, `description`, `tags`, `repo_url`
|
||||||
|
- `analysis.md` sigue el template de `docs/templates/analysis.md` — `dir_path` debe apuntar a `projects/{nombre}/analysis/{tema}/`
|
||||||
|
- `vault.yaml` lista los vaults con nombre, descripcion, path absoluto y tags
|
||||||
|
- Los vaults reales viven fuera del repo (ej: `~/vaults/{nombre}/`) con symlinks en el proyecto
|
||||||
|
- `fn index` escanea `projects/*/` y setea `project_id` automaticamente en apps, analyses y vaults
|
||||||
|
- Apps y analyses sueltos (sin proyecto) siguen en `apps/` y `analysis/` en la raiz
|
||||||
|
|
||||||
|
### Raiz vs proyecto
|
||||||
|
|
||||||
|
| Ubicacion | Para que |
|
||||||
|
|-----------|---------|
|
||||||
|
| `apps/` | Apps independientes que no pertenecen a ningun proyecto |
|
||||||
|
| `analysis/` | Analyses independientes |
|
||||||
|
| `projects/{nombre}/apps/` | Apps de un proyecto — `project_id` se setea automaticamente |
|
||||||
|
| `projects/{nombre}/analysis/` | Analyses de un proyecto — `project_id` se setea automaticamente |
|
||||||
|
|
||||||
|
### Crear un proyecto nuevo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Crear estructura
|
||||||
|
mkdir -p projects/{nombre}/{apps,analysis,vaults}
|
||||||
|
|
||||||
|
# 2. Crear project.md con frontmatter
|
||||||
|
fn add -k project # genera template
|
||||||
|
|
||||||
|
# 3. Crear vault (datos fuera del repo, symlink dentro)
|
||||||
|
mkdir -p ~/vaults/{vault_name}/{raw,processed,exports}
|
||||||
|
ln -s ~/vaults/{vault_name} projects/{nombre}/vaults/{vault_name}
|
||||||
|
# Crear vault.yaml con la entrada
|
||||||
|
|
||||||
|
# 4. Crear analysis dentro del proyecto (un solo comando; ya indexa)
|
||||||
|
fn run init_jupyter_analysis --project {nombre} {nombre_analysis} --desc "..." [paquetes...]
|
||||||
|
|
||||||
|
# 5. Verificar
|
||||||
|
fn show {nombre} # verifica el project y sus componentes
|
||||||
|
|
||||||
|
# NUNCA: crear el analisis en analysis/ y luego mv al proyecto.
|
||||||
|
# Al mover se rompe el .venv (paths hardcodeados en activate).
|
||||||
|
# Si ya te paso: cd projects/{nombre}/analysis/{tema} && rm -rf .venv && uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consultas utiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Listar proyectos
|
||||||
|
SELECT id, description FROM projects;
|
||||||
|
|
||||||
|
-- Analysis de un proyecto
|
||||||
|
SELECT id, name, dir_path FROM analysis WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Vaults de un proyecto
|
||||||
|
SELECT id, name, path, symlink FROM vaults WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Apps de un proyecto
|
||||||
|
SELECT id, name, dir_path FROM apps WHERE project_id = 'app_turismo';
|
||||||
|
|
||||||
|
-- Todo lo que pertenece a un proyecto
|
||||||
|
SELECT 'analysis' as tipo, id, name FROM analysis WHERE project_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'vault', id, name FROM vaults WHERE project_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'app', id, name FROM apps WHERE project_id = ?;
|
||||||
|
```
|
||||||
Executable
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Extract Claude Design "standalone" HTML exports.
|
||||||
|
|
||||||
|
Claude Design packs the whole React app as base64+gzip blobs inside
|
||||||
|
<script type="__bundler/manifest"> tags. This script decompresses them
|
||||||
|
and writes each asset (JSX, CSS, fonts) to a target directory.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 extract_design_bundle.py <path/to/export.html> <output_dir>
|
||||||
|
|
||||||
|
The output dir will contain:
|
||||||
|
data.jsx (if detected by header comment)
|
||||||
|
fn_library_emu.jsx (lib emulation)
|
||||||
|
charts_emu.jsx (charts emulation)
|
||||||
|
app.jsx (main tree)
|
||||||
|
<uuid>.<ext> (anything else — fonts, unknown js)
|
||||||
|
manifest.json (summary of all assets: uuid, mime, bytes, filename)
|
||||||
|
|
||||||
|
JSX files are named heuristically from their leading comment. If names
|
||||||
|
cannot be inferred from headers, they keep their uuid prefix.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
MIME_TO_EXT = {
|
||||||
|
"text/javascript": "js",
|
||||||
|
"application/javascript": "js",
|
||||||
|
"text/babel": "jsx",
|
||||||
|
"application/json": "json",
|
||||||
|
"text/css": "css",
|
||||||
|
"image/svg+xml": "svg",
|
||||||
|
"font/woff2": "woff2",
|
||||||
|
"font/woff": "woff",
|
||||||
|
"text/html": "html",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order matters: first matching hint wins. Put MORE SPECIFIC patterns first.
|
||||||
|
HEADER_HINTS = [
|
||||||
|
("charts_emu.jsx", [r"Emulaci(ó|o)n de @fn_library/\{", r"LineChart, AreaChart, BarChart"]),
|
||||||
|
("fn_library_emu.jsx", [r"Emulaci(ó|o)n visual de @fn_library"]),
|
||||||
|
("data.jsx", [r"mock data \(determinista\)", r"window\.\w+Data\s*="]),
|
||||||
|
("app.jsx", [r"ReactDOM\.createRoot", r"arbol principal", r"function App\s*\("]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_name(content: str, used_names: set[str]) -> str | None:
|
||||||
|
head = content[:2000]
|
||||||
|
for name, patterns in HEADER_HINTS:
|
||||||
|
if name in used_names:
|
||||||
|
continue
|
||||||
|
if any(re.search(p, head, re.IGNORECASE) for p in patterns):
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def grab_script(html: str, kind: str) -> str | None:
|
||||||
|
m = re.search(
|
||||||
|
r'<script type="__bundler/' + kind + r'">\s*(.*?)\s*</script>',
|
||||||
|
html, re.DOTALL,
|
||||||
|
)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract(html_path: pathlib.Path, out_dir: pathlib.Path) -> dict:
|
||||||
|
html = html_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
manifest_raw = grab_script(html, "manifest")
|
||||||
|
if not manifest_raw:
|
||||||
|
raise SystemExit(f"No <script type='__bundler/manifest'> found in {html_path}")
|
||||||
|
manifest = json.loads(manifest_raw)
|
||||||
|
|
||||||
|
ext_raw = grab_script(html, "ext_resources")
|
||||||
|
ext_resources = json.loads(ext_raw) if ext_raw else []
|
||||||
|
id_map = {e["uuid"]: e.get("id", e["uuid"]) for e in ext_resources}
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
used_names: set[str] = set()
|
||||||
|
|
||||||
|
# First pass: decode all assets and collect jsx blobs (so we can name them by header hint)
|
||||||
|
decoded: list[tuple[str, str, bytes]] = [] # (uuid, mime, bytes)
|
||||||
|
for uuid, entry in manifest.items():
|
||||||
|
raw = base64.b64decode(entry["data"])
|
||||||
|
if entry.get("compressed"):
|
||||||
|
raw = gzip.decompress(raw)
|
||||||
|
decoded.append((uuid, entry.get("mime", "application/octet-stream"), raw))
|
||||||
|
|
||||||
|
# Second pass: write files with heuristic names for known jsx
|
||||||
|
for uuid, mime, raw in decoded:
|
||||||
|
ext = MIME_TO_EXT.get(mime, "bin")
|
||||||
|
filename = None
|
||||||
|
|
||||||
|
# Heuristic for JSX / JS that represents the app
|
||||||
|
if ext in ("jsx", "js"):
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
name = pick_name(text, used_names)
|
||||||
|
if name:
|
||||||
|
filename = name
|
||||||
|
used_names.add(name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
# Fall back to ext_resources id if present, or uuid
|
||||||
|
base = id_map.get(uuid, uuid)
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80]
|
||||||
|
filename = f"{safe}.{ext}"
|
||||||
|
|
||||||
|
path = out_dir / filename
|
||||||
|
# Avoid collisions
|
||||||
|
i = 2
|
||||||
|
while path.exists():
|
||||||
|
stem = path.stem
|
||||||
|
path = out_dir / f"{stem}_{i}.{ext}"
|
||||||
|
i += 1
|
||||||
|
path.write_bytes(raw)
|
||||||
|
summary.append({
|
||||||
|
"uuid": uuid,
|
||||||
|
"mime": mime,
|
||||||
|
"bytes": len(raw),
|
||||||
|
"filename": path.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
(out_dir / "manifest.json").write_text(
|
||||||
|
json.dumps({"source": str(html_path), "assets": summary}, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return {"assets": summary, "out": str(out_dir)}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(2)
|
||||||
|
html = pathlib.Path(sys.argv[1])
|
||||||
|
out = pathlib.Path(sys.argv[2])
|
||||||
|
if not html.exists():
|
||||||
|
sys.exit(f"Input not found: {html}")
|
||||||
|
result = extract(html, out)
|
||||||
|
print(f"✓ Extracted {len(result['assets'])} assets to {result['out']}")
|
||||||
|
print(f" Manifest: {out}/manifest.json")
|
||||||
|
print()
|
||||||
|
# Short preview per asset
|
||||||
|
for a in result["assets"]:
|
||||||
|
print(f" {a['mime']:28s} {a['bytes']:>8} B {a['filename']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+26
-1
@@ -1,6 +1,8 @@
|
|||||||
# SQLite index — journal/wal temporales
|
# SQLite index — regenerable con `fn index` + completable con `fn sync`
|
||||||
|
registry.db
|
||||||
registry.db-journal
|
registry.db-journal
|
||||||
registry.db-wal
|
registry.db-wal
|
||||||
|
registry.db-shm
|
||||||
|
|
||||||
# operations.db — datos vivos, cada app genera el suyo con fn ops init
|
# operations.db — datos vivos, cada app genera el suyo con fn ops init
|
||||||
**/operations.db
|
**/operations.db
|
||||||
@@ -37,12 +39,34 @@ python/.venv/
|
|||||||
apps/*/
|
apps/*/
|
||||||
analysis/*/
|
analysis/*/
|
||||||
|
|
||||||
|
# Projects (each is its own git repo, only project.md templates are versioned)
|
||||||
|
projects/*/
|
||||||
|
|
||||||
|
# Vaults — data stores (symlinks, dirs, files); only vault.yaml manifest is versioned
|
||||||
|
vaults/*/
|
||||||
|
!vaults/vault.yaml
|
||||||
|
|
||||||
# Node / pnpm
|
# Node / pnpm
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
|
||||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||||
sources/*/
|
sources/*/
|
||||||
|
|
||||||
|
# Subrepos — mirrors/espejos externos (cada uno su propio git remote)
|
||||||
|
subrepos/*/
|
||||||
|
|
||||||
|
# External — symlinks a repos ajenos (ej: repo_Claude con skills/commands)
|
||||||
|
external/
|
||||||
|
|
||||||
|
# Worktrees — git worktrees para issues paralelos (parallel-fix-issues)
|
||||||
|
worktrees/
|
||||||
|
|
||||||
|
# Claude runtime locks
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||||
|
temp/
|
||||||
|
|
||||||
# C++ build artifacts
|
# C++ build artifacts
|
||||||
cpp/build/
|
cpp/build/
|
||||||
|
|
||||||
@@ -55,3 +79,4 @@ Thumbs.db
|
|||||||
|
|
||||||
broken_paths.txt
|
broken_paths.txt
|
||||||
imgui.ini
|
imgui.ini
|
||||||
|
prompts/
|
||||||
|
|||||||
+5
-2
@@ -8,6 +8,9 @@
|
|||||||
[submodule "cpp/vendor/tracy"]
|
[submodule "cpp/vendor/tracy"]
|
||||||
path = cpp/vendor/tracy
|
path = cpp/vendor/tracy
|
||||||
url = https://github.com/wolfpld/tracy.git
|
url = https://github.com/wolfpld/tracy.git
|
||||||
[submodule "/home/lucas/fn_registry/cpp/vendor/glfw"]
|
[submodule "cpp/vendor/glfw"]
|
||||||
path = /home/lucas/fn_registry/cpp/vendor/glfw
|
path = cpp/vendor/glfw
|
||||||
url = https://github.com/glfw/glfw.git
|
url = https://github.com/glfw/glfw.git
|
||||||
|
[submodule "cpp/vendor/implot3d"]
|
||||||
|
path = cpp/vendor/implot3d
|
||||||
|
url = https://github.com/brenocq/implot3d.git
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Todos los cambios notables de `fn_registry` se documentan aquí.
|
||||||
|
|
||||||
|
Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). Al no haber releases semver formales, las entradas se ordenan por fecha.
|
||||||
|
|
||||||
|
Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones arquitecturales ver `docs/adr/`.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 2026-04-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 6 funciones `bash/infra/systemd_local_*` (install_unit, enable, start, restart, status, uninstall) para gestionar servicios systemd del sistema desde el registry (complementa las versiones remotas SSH ya existentes).
|
||||||
|
- Pipeline `install_systemd_service_bash_pipelines` que compone las anteriores: genera unit file + install + enable + start + status.
|
||||||
|
- Servicio systemd `sqlite_api.service` instalado y habilitado en aurgi-pc — arranque automático al iniciar WSL en `127.0.0.1:8484`.
|
||||||
|
- `projects/fn_monitoring/launcher.sh` — launcher del dashboard (arranca API si no está + lanza ventana + cleanup).
|
||||||
|
- Regla [`.claude/rules/kiss.md`](.claude/rules/kiss.md) — filosofía KISS para proyectos y apps.
|
||||||
|
- Documentación ADR en `docs/adr/` con plantilla y ADR 0001 (experimento GitButler).
|
||||||
|
- Diario en `docs/diary/` + slash command `/entrada_diario` para añadir entradas.
|
||||||
|
- `CHANGELOG.md` (este archivo).
|
||||||
|
- Submódulo `cpp/vendor/glfw` re-registrado con path limpio (antes heredado con path absoluto `/home/lucas/...`).
|
||||||
|
- aurgi-pc registrado en el server centralizado (`registry.organic-machine.com`) con 18 pc_locations.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `registry.db` ahora está gitignorada. Es regenerable con `fn index` + completable con `fn sync`. Evita conflictos entre ramas y PCs.
|
||||||
|
- `sqlite_api` ahora se distribuye como binario compilado (`projects/fn_monitoring/apps/sqlite_api/sqlite_api`) en lugar de `go run` al vuelo.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `http_client.cpp` del dashboard: añadido `#include <cstdint>` requerido por mingw-w64 para cross-compile Windows (g++ Linux lo incluía transitivamente).
|
||||||
|
- `registry_dashboard.exe` (Windows) ya no abre ventana de consola al lanzarse — enlazado como GUI app (`WIN32_EXECUTABLE TRUE` / `-mwindows`).
|
||||||
|
|
||||||
|
### Added (design system C++)
|
||||||
|
|
||||||
|
- `cpp/functions/core/tokens` — design tokens para dashboards ImGui (colors, spacing, radius, font_size) inspirados en `@fn_library` (Mantine v9). Paleta dark + indigo primary. `apply_dark_theme()` aplica los tokens al `ImGuiStyle` global.
|
||||||
|
- `cpp/functions/core/badge` — etiqueta inline con 6 variantes (Default/Success/Warning/Error/Info/Outline). Equivalente a `<Badge>` de `@fn_library`.
|
||||||
|
- `cpp/functions/core/empty_state` — placeholder centrado para tablas/listas vacías.
|
||||||
|
- `cpp/functions/core/page_header` — header de página con título/subtítulo + hueco para acciones + separator.
|
||||||
|
- `registry_dashboard` migrado a los nuevos componentes: `page_header_begin/end` en el header, `empty_state` en las 4 tablas cuando están vacías, `apply_dark_theme()` al primer frame. Sin hardcode de colores disperso.
|
||||||
|
- `systemd_local_{enable,start,restart}`: stdout de `systemctl` redirigido a stderr para no contaminar el JSON capturado por el pipeline.
|
||||||
|
- `.gitmodules`: entry fantasma `cpp/vendor/glfw` con path absoluto `/home/lucas/...` que bloqueaba `git submodule status` y el cross-compile Windows.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Integración de GitButler de Claude Code — binario `~/.local/bin/but`, plugin `gitbutler-tools`, skill `.claude/skills/gitbutler/`, hooks en `settings.json`, ramas `gitbutler/*` + `e-branch-*`, estado interno `.git/gitbutler/`. Ver [ADR 0001](docs/adr/0001-gitbutler-experiment.md) para motivos.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Build output
|
||||||
|
dag_engine
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAPI sets up all HTTP routes on the given mux.
|
||||||
|
func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, frontendFS fs.FS) {
|
||||||
|
// API routes.
|
||||||
|
mux.HandleFunc("GET /api/dags", handleListDags(executor))
|
||||||
|
mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor))
|
||||||
|
mux.HandleFunc("POST /api/dags/{name}/run", handleRunDag(executor))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/runs", handleListRuns(executor))
|
||||||
|
mux.HandleFunc("GET /api/runs/{id}", handleGetRun(executor))
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/scheduler/start", handleSchedulerStart(scheduler))
|
||||||
|
mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler))
|
||||||
|
mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler))
|
||||||
|
|
||||||
|
// Frontend SPA fallback.
|
||||||
|
if frontendFS != nil {
|
||||||
|
mux.Handle("/", spaHandler(frontendFS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves static files from the embedded FS, falling back to index.html
|
||||||
|
// for unknown paths (SPA client-side routing).
|
||||||
|
func spaHandler(fsys fs.FS) http.Handler {
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try to serve the file directly.
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "index.html"
|
||||||
|
} else {
|
||||||
|
path = path[1:] // strip leading /
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(fsys, path); err != nil {
|
||||||
|
// File not found — serve index.html for SPA routing.
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: dag_engine
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
description: "Motor de ejecucion de DAGs con CLI y interfaz web. Reemplaza Dagu con implementacion propia compatible con el formato YAML existente. Almacena historial de ejecuciones en SQLite."
|
||||||
|
tags: [service, dag, workflow, scheduler, web, cron]
|
||||||
|
uses_functions:
|
||||||
|
- dag_parse_go_core
|
||||||
|
- dag_validate_go_core
|
||||||
|
- dag_topo_sort_go_core
|
||||||
|
- dag_resolve_env_go_core
|
||||||
|
- parse_cron_expr_go_core
|
||||||
|
- next_cron_time_go_core
|
||||||
|
- cron_ticker_go_infra
|
||||||
|
- cron_match_go_core
|
||||||
|
- process_spawn_go_infra
|
||||||
|
- process_wait_go_infra
|
||||||
|
- process_kill_go_infra
|
||||||
|
uses_types:
|
||||||
|
- dag_definition_go_core
|
||||||
|
- dag_step_go_core
|
||||||
|
- dag_validation_result_go_core
|
||||||
|
- cron_schedule_go_core
|
||||||
|
- process_handle_go_infra
|
||||||
|
- process_result_go_infra
|
||||||
|
- DagRun_go_infra
|
||||||
|
- DagStepResult_go_infra
|
||||||
|
framework: "net/http + vite + react"
|
||||||
|
entry_point: "main.go"
|
||||||
|
dir_path: "apps/dag_engine"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
CLI + servidor web en un unico binario:
|
||||||
|
|
||||||
|
```
|
||||||
|
dag-engine run <path.yaml> # ejecuta un DAG desde terminal
|
||||||
|
dag-engine list [dir] # lista DAGs con schedule y estado
|
||||||
|
dag-engine status [dag_name] # historial de ejecuciones
|
||||||
|
dag-engine validate <path.yaml> # valida sin ejecutar
|
||||||
|
dag-engine server # arranca HTTP + frontend web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Go)
|
||||||
|
|
||||||
|
- `net/http` con `ServeMux` (Go 1.22+ pattern routing)
|
||||||
|
- SQLite via `go-sqlite3` para historial de runs
|
||||||
|
- Executor: parse -> validate -> topo_sort -> spawn/wait por nivel -> store
|
||||||
|
- Scheduler: cron_ticker por cada DAG con schedule
|
||||||
|
|
||||||
|
### Frontend (Vite + React + Mantine)
|
||||||
|
|
||||||
|
- DagList: tabla de DAGs con schedule, tags, ultimo status
|
||||||
|
- DagDetail: metadata + "Run Now" + historial
|
||||||
|
- RunDetail: timeline de steps con stdout/stderr expandible
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
SQLite `dag_engine.db`:
|
||||||
|
- `dag_runs`: id, dag_name, status, trigger, started_at, finished_at, error
|
||||||
|
- `dag_step_results`: id, run_id, step_name, status, exit_code, stdout, stderr, duration_ms
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install && pnpm build
|
||||||
|
cd .. && CGO_ENABLED=1 go build -tags fts5 -o dag-engine .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI
|
||||||
|
./dag-engine run ~/dagu/dags/example.yaml
|
||||||
|
./dag-engine list ~/dagu/dags/
|
||||||
|
|
||||||
|
# Servidor web
|
||||||
|
./dag-engine server --port 8090 --dags-dir ~/dagu/dags/ --scheduler
|
||||||
|
# Browser: http://localhost:8090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Compatible con el formato YAML de Dagu. Lee DAGs existentes de `~/dagu/dags/` sin modificaciones.
|
||||||
|
Puerto por defecto 8090 (mismo que Dagu).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the runtime configuration for the DAG engine.
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DagsDir string
|
||||||
|
DBPath string
|
||||||
|
AutoScheduler bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns sensible defaults.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return Config{
|
||||||
|
Port: 8090,
|
||||||
|
DagsDir: filepath.Join(home, "dagu", "dags"),
|
||||||
|
DBPath: "dag_engine.db",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFlags populates config from CLI flags for the "server" subcommand.
|
||||||
|
func (c *Config) ParseFlags(fs *flag.FlagSet, args []string) error {
|
||||||
|
fs.IntVar(&c.Port, "port", c.Port, "HTTP server port")
|
||||||
|
fs.StringVar(&c.DagsDir, "dags-dir", c.DagsDir, "directory containing DAG YAML files")
|
||||||
|
fs.StringVar(&c.DBPath, "db", c.DBPath, "path to SQLite database")
|
||||||
|
fs.BoolVar(&c.AutoScheduler, "scheduler", c.AutoScheduler, "auto-start cron scheduler")
|
||||||
|
return fs.Parse(args)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor orchestrates DAG parsing, validation, and execution.
|
||||||
|
type Executor struct {
|
||||||
|
store *store.DB
|
||||||
|
dagsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new executor.
|
||||||
|
func NewExecutor(s *store.DB, dagsDir string) *Executor {
|
||||||
|
return &Executor{store: s, dagsDir: dagsDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteDAG runs a DAG from a YAML file path and returns the run ID.
|
||||||
|
// It runs asynchronously: steps execute in topological order with parallel levels.
|
||||||
|
func (e *Executor) ExecuteDAG(ctx context.Context, dagPath string, trigger string) (string, error) {
|
||||||
|
data, err := os.ReadFile(dagPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read dag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse dag: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = dagPath
|
||||||
|
|
||||||
|
// Resolve env variables.
|
||||||
|
dag = core.DagResolveEnv(dag, os.Environ())
|
||||||
|
|
||||||
|
// Validate.
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
if !result.Valid {
|
||||||
|
return "", fmt.Errorf("validate dag: %s", strings.Join(result.Errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create run record.
|
||||||
|
runID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
run := &store.DagRun{
|
||||||
|
ID: runID,
|
||||||
|
DagName: dag.Name,
|
||||||
|
DagPath: dagPath,
|
||||||
|
Status: "running",
|
||||||
|
Trigger: trigger,
|
||||||
|
StartedAt: now,
|
||||||
|
}
|
||||||
|
if err := e.store.CreateRun(run); err != nil {
|
||||||
|
return "", fmt.Errorf("create run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort.
|
||||||
|
levels, err := core.DagTopoSort(dag.Steps)
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup DAGU_ENV temp file for inter-step communication.
|
||||||
|
daguEnvFile, err := os.CreateTemp("", "dagu_env_*")
|
||||||
|
if err != nil {
|
||||||
|
e.failRun(runID, err)
|
||||||
|
return runID, err
|
||||||
|
}
|
||||||
|
daguEnvPath := daguEnvFile.Name()
|
||||||
|
daguEnvFile.Close()
|
||||||
|
defer os.Remove(daguEnvPath)
|
||||||
|
|
||||||
|
// Track step outputs for ${step_id.stdout} references.
|
||||||
|
stepOutputs := make(map[string]string)
|
||||||
|
|
||||||
|
// Execute levels.
|
||||||
|
runFailed := false
|
||||||
|
var runErr error
|
||||||
|
|
||||||
|
for _, level := range levels {
|
||||||
|
if runFailed {
|
||||||
|
// Skip remaining levels, mark steps as skipped.
|
||||||
|
for _, step := range level {
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
levelFailed := false
|
||||||
|
|
||||||
|
for _, step := range level {
|
||||||
|
step := step
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if levelFailed {
|
||||||
|
mu.Unlock()
|
||||||
|
e.recordStepSkipped(runID, step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
err := e.executeStep(ctx, runID, dag, step, daguEnvPath, stepOutputs, &mu)
|
||||||
|
if err != nil && !step.ContinueOn.Failure {
|
||||||
|
mu.Lock()
|
||||||
|
levelFailed = true
|
||||||
|
runFailed = true
|
||||||
|
runErr = fmt.Errorf("step %q failed: %w", stepName(step), err)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run handlers.
|
||||||
|
if runFailed {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Failure, daguEnvPath, stepOutputs)
|
||||||
|
} else {
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Success, daguEnvPath, stepOutputs)
|
||||||
|
}
|
||||||
|
e.runHandlers(ctx, runID, dag, dag.HandlerOn.Exit, daguEnvPath, stepOutputs)
|
||||||
|
|
||||||
|
// Finalize run.
|
||||||
|
fin := time.Now()
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if runFailed {
|
||||||
|
status = "failed"
|
||||||
|
if runErr != nil {
|
||||||
|
errMsg = runErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.store.UpdateRunStatus(runID, status, &fin, errMsg)
|
||||||
|
|
||||||
|
return runID, runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeStep runs a single step, recording results in the store.
|
||||||
|
func (e *Executor) executeStep(ctx context.Context, runID string, dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string, mu *sync.Mutex) error {
|
||||||
|
stepID := generateID()
|
||||||
|
now := time.Now()
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: stepID,
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
Status: "running",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build environment.
|
||||||
|
env := buildStepEnv(dag, step, daguEnvPath, outputs)
|
||||||
|
|
||||||
|
// Determine command.
|
||||||
|
command := step.Command
|
||||||
|
if command == "" && step.Script != "" {
|
||||||
|
command = step.Script
|
||||||
|
}
|
||||||
|
if command == "" {
|
||||||
|
e.store.UpdateStepResult(stepID, "skipped", 0, "", "", nil, 0, "no command or script")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve step-level ${VAR} references and ${step_id.stdout} patterns.
|
||||||
|
mu.Lock()
|
||||||
|
command = resolveStepRefs(command, outputs)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Determine working directory.
|
||||||
|
dir := step.Dir
|
||||||
|
if dir == "" {
|
||||||
|
dir = dag.WorkingDir
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := step.Shell
|
||||||
|
if shell == "" {
|
||||||
|
shell = dag.Shell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn process.
|
||||||
|
handle, err := infra.ProcessSpawn(command, dir, env, shell)
|
||||||
|
if err != nil {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateStepResult(stepID, "failed", -1, "", "", &fin, time.Since(now).Milliseconds(), err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process.
|
||||||
|
result, err := infra.ProcessWait(handle, step.TimeoutSec)
|
||||||
|
fin := time.Now()
|
||||||
|
duration := time.Since(now).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil && result.ExitCode == 0 {
|
||||||
|
result.ExitCode = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if result.ExitCode != 0 || err != nil {
|
||||||
|
status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.store.UpdateStepResult(stepID, status, result.ExitCode, result.Stdout, result.Stderr, &fin, duration, errMsg)
|
||||||
|
|
||||||
|
// Store output for ${step_id.stdout} references.
|
||||||
|
if step.ID != "" || step.Output != "" {
|
||||||
|
mu.Lock()
|
||||||
|
key := step.ID
|
||||||
|
if key == "" {
|
||||||
|
key = step.Output
|
||||||
|
}
|
||||||
|
outputs[key] = strings.TrimSpace(result.Stdout)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read DAGU_ENV for inter-step env propagation.
|
||||||
|
readDaguEnv(daguEnvPath, outputs)
|
||||||
|
|
||||||
|
if status == "failed" {
|
||||||
|
return fmt.Errorf("exit code %d", result.ExitCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) runHandlers(ctx context.Context, runID string, dag core.DagDefinition, handlers []core.DagStep, daguEnvPath string, outputs map[string]string) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
for _, step := range handlers {
|
||||||
|
e.executeStep(ctx, runID, dag, step, daguEnvPath, outputs, &mu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) failRun(runID string, err error) {
|
||||||
|
fin := time.Now()
|
||||||
|
e.store.UpdateRunStatus(runID, "failed", &fin, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) recordStepSkipped(runID string, step core.DagStep) {
|
||||||
|
now := time.Now()
|
||||||
|
e.store.InsertStepResult(&store.DagStepResult{
|
||||||
|
ID: generateID(),
|
||||||
|
RunID: runID,
|
||||||
|
StepName: stepName(step),
|
||||||
|
Status: "skipped",
|
||||||
|
StartedAt: &now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func stepName(s core.DagStep) string {
|
||||||
|
if s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStepEnv(dag core.DagDefinition, step core.DagStep, daguEnvPath string, outputs map[string]string) []string {
|
||||||
|
env := os.Environ()
|
||||||
|
|
||||||
|
// Add DAG-level env.
|
||||||
|
for k, v := range dag.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add step-level env.
|
||||||
|
for k, v := range step.Env {
|
||||||
|
env = append(env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add DAGU_ENV path.
|
||||||
|
env = append(env, "DAGU_ENV="+daguEnvPath)
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStepRefs(command string, outputs map[string]string) string {
|
||||||
|
for k, v := range outputs {
|
||||||
|
command = strings.ReplaceAll(command, "${"+k+".stdout}", v)
|
||||||
|
command = strings.ReplaceAll(command, "$"+k+".stdout", v)
|
||||||
|
}
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDaguEnv(path string, outputs map[string]string) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
outputs[parts[0]] = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID creates a simple time-based unique ID.
|
||||||
|
func generateID() string {
|
||||||
|
return fmt.Sprintf("%d-%04x", time.Now().UnixNano(), time.Now().Nanosecond()%0xFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DAG listing helpers ---
|
||||||
|
|
||||||
|
// DagInfo summarizes a DAG file for listing.
|
||||||
|
type DagInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Schedule []string `json:"schedule,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
LastRun *store.DagRun `json:"last_run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDAGs scans a directory for YAML files and returns parsed DAG info.
|
||||||
|
func (e *Executor) ListDAGs() ([]DagInfo, error) {
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read dags dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dags []DagInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
dags = append(dags, DagInfo{
|
||||||
|
Name: strings.TrimSuffix(entry.Name(), ext),
|
||||||
|
FilePath: path,
|
||||||
|
Valid: false,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach last run info.
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
dags = append(dags, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDAG returns detailed info for a specific DAG by name.
|
||||||
|
func (e *Executor) GetDAG(name string) (*DagInfo, *core.DagDefinition, *core.DagValidationResult, error) {
|
||||||
|
// Find the YAML file.
|
||||||
|
entries, err := os.ReadDir(e.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
base := strings.TrimSuffix(entry.Name(), ext)
|
||||||
|
if (ext != ".yaml" && ext != ".yml") || base != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(e.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("parse: %w", err)
|
||||||
|
}
|
||||||
|
dag.FilePath = path
|
||||||
|
|
||||||
|
validationResult := core.DagValidate(dag)
|
||||||
|
|
||||||
|
info := &DagInfo{
|
||||||
|
Name: dag.Name,
|
||||||
|
Description: dag.Description,
|
||||||
|
Schedule: dag.Schedule,
|
||||||
|
Tags: dag.Tags,
|
||||||
|
Type: dag.Type,
|
||||||
|
FilePath: path,
|
||||||
|
Valid: validationResult.Valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, _, _ := e.store.ListRuns(dag.Name, 1, 0)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
info.LastRun = &runs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, &dag, &validationResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, nil, fmt.Errorf("dag %q not found in %s", name, e.dagsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDAG parses and validates a DAG file, printing results.
|
||||||
|
func ValidateDAG(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
log.Printf("DAG: %s", dag.Name)
|
||||||
|
log.Printf("Steps: %d", len(dag.Steps))
|
||||||
|
log.Printf("Schedule: %v", dag.Schedule)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
log.Printf("Validation: PASS")
|
||||||
|
log.Printf("Topological levels: %d", len(result.Levels))
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
log.Printf(" Level %d: %v", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
log.Printf(" ERROR: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
log.Printf(" WARNING: %s", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
return fmt.Errorf("validation failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DAG Engine</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "dag-engine-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^9.0.2",
|
||||||
|
"@mantine/hooks": "^9.0.2",
|
||||||
|
"@tabler/icons-react": "^3.31.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"postcss": "^8.5.4",
|
||||||
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { AppShell, Container, Title, Group, Text } from "@mantine/core";
|
||||||
|
import { IconTopologyRing } from "@tabler/icons-react";
|
||||||
|
import { DagList } from "./pages/DagList";
|
||||||
|
import { DagDetail } from "./pages/DagDetail";
|
||||||
|
import { RunDetail } from "./pages/RunDetail";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppShell header={{ height: 50 }} padding="md">
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<IconTopologyRing size={24} />
|
||||||
|
<Title order={4}>DAG Engine</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
fn_registry workflow executor
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Main>
|
||||||
|
<Container size="lg">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DagList />} />
|
||||||
|
<Route path="/dags/:name" element={<DagDetail />} />
|
||||||
|
<Route path="/runs/:id" element={<RunDetail />} />
|
||||||
|
</Routes>
|
||||||
|
</Container>
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
DagSummary,
|
||||||
|
DagDetail,
|
||||||
|
DagRun,
|
||||||
|
RunDetail,
|
||||||
|
SchedulerStatus,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const BASE = "/api";
|
||||||
|
|
||||||
|
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, init);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDags(): Promise<DagSummary[]> {
|
||||||
|
return fetchJSON("/dags");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDag(name: string): Promise<DagDetail> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDag(
|
||||||
|
name: string
|
||||||
|
): Promise<{ status: string; dag: string; message: string }> {
|
||||||
|
return fetchJSON(`/dags/${encodeURIComponent(name)}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRuns(params?: {
|
||||||
|
dag?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<{ runs: DagRun[]; total: number }> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.dag) search.set("dag", params.dag);
|
||||||
|
if (params?.limit) search.set("limit", String(params.limit));
|
||||||
|
if (params?.offset) search.set("offset", String(params.offset));
|
||||||
|
const qs = search.toString();
|
||||||
|
return fetchJSON(`/runs${qs ? "?" + qs : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(id: string): Promise<RunDetail> {
|
||||||
|
return fetchJSON(`/runs/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/start", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): Promise<void> {
|
||||||
|
return fetchJSON("/scheduler/stop", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSchedulerStatus(): Promise<SchedulerStatus> {
|
||||||
|
return fetchJSON("/scheduler/status");
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
success: "green",
|
||||||
|
failed: "red",
|
||||||
|
running: "blue",
|
||||||
|
pending: "gray",
|
||||||
|
cancelled: "yellow",
|
||||||
|
skipped: "dimmed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge color={colorMap[status] || "gray"} variant="light" size="sm">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Timeline, Text, Code, Collapse, Box, Group } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleX,
|
||||||
|
IconLoader,
|
||||||
|
IconCircleMinus,
|
||||||
|
IconClock,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import type { DagStepResult } from "../types";
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
success: <IconCircleCheck size={16} color="var(--mantine-color-green-6)" />,
|
||||||
|
failed: <IconCircleX size={16} color="var(--mantine-color-red-6)" />,
|
||||||
|
running: <IconLoader size={16} color="var(--mantine-color-blue-6)" />,
|
||||||
|
skipped: <IconCircleMinus size={16} color="var(--mantine-color-dimmed)" />,
|
||||||
|
pending: <IconClock size={16} color="var(--mantine-color-gray-6)" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function StepItem({ step }: { step: DagStepResult }) {
|
||||||
|
const [opened, { toggle }] = useDisclosure(step.Status === "failed");
|
||||||
|
const hasOutput = step.Stdout || step.Stderr;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
bullet={iconMap[step.Status] || iconMap.pending}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
onClick={hasOutput ? toggle : undefined}
|
||||||
|
style={hasOutput ? { cursor: "pointer" } : undefined}
|
||||||
|
>
|
||||||
|
{step.StepName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{step.DurationMs}ms
|
||||||
|
</Text>
|
||||||
|
{step.ExitCode !== 0 && step.ExitCode !== -1 && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
exit {step.ExitCode}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasOutput && (
|
||||||
|
<Collapse in={opened}>
|
||||||
|
<Box mt="xs">
|
||||||
|
{step.Stdout && (
|
||||||
|
<Code block mb="xs" style={{ maxHeight: 200, overflow: "auto" }}>
|
||||||
|
{step.Stdout}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
{step.Stderr && (
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
color="red"
|
||||||
|
style={{ maxHeight: 200, overflow: "auto" }}
|
||||||
|
>
|
||||||
|
{step.Stderr}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepTimeline({ steps }: { steps: DagStepResult[] }) {
|
||||||
|
const activeIndex = steps.findIndex((s) => s.Status === "running");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
active={activeIndex >= 0 ? activeIndex : steps.length - 1}
|
||||||
|
bulletSize={24}
|
||||||
|
>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepItem key={step.ID} step={step} />
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: "blue",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Code,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPlayerPlay, IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { getDag, triggerDag } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagDetail as DagDetailType } from "../types";
|
||||||
|
|
||||||
|
export function DagDetail() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<DagDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [triggering, setTriggering] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await getDag(name));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
setTriggering(true);
|
||||||
|
try {
|
||||||
|
await triggerDag(name);
|
||||||
|
setTimeout(load, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setTriggering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { dag, validation, runs } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>{dag.Name}</Title>
|
||||||
|
{dag.Description && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{dag.Description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={handleRun}
|
||||||
|
loading={triggering}
|
||||||
|
>
|
||||||
|
Run Now
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
{dag.Schedule?.map((s: string) => (
|
||||||
|
<Badge key={s} variant="light" ff="monospace">
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Badge variant="light">{dag.Type || "chain"}</Badge>
|
||||||
|
{dag.Tags?.map((t: string) => (
|
||||||
|
<Badge key={t} variant="dot">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!validation.Valid && (
|
||||||
|
<Alert color="red" title="Validation errors">
|
||||||
|
{validation.Errors.map((e: string, i: number) => (
|
||||||
|
<Text key={i} size="sm">
|
||||||
|
{e}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Steps ({dag.Steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{validation.Levels?.map((level: string[], i: number) => (
|
||||||
|
<Group key={i} gap="xs" mb="xs">
|
||||||
|
<Text size="xs" c="dimmed" w={60}>
|
||||||
|
Level {i}:
|
||||||
|
</Text>
|
||||||
|
{level.map((name: string) => {
|
||||||
|
const step = dag.Steps?.find(
|
||||||
|
(s) => s.Name === name || s.ID === name
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge key={name} variant="outline" size="sm">
|
||||||
|
{name}
|
||||||
|
{step?.Depends?.length
|
||||||
|
? ` (after ${step.Depends.join(",")})`
|
||||||
|
: ""}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{dag.Env && Object.keys(dag.Env).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Title order={5} mt="md" mb="xs">
|
||||||
|
Environment
|
||||||
|
</Title>
|
||||||
|
<Code block>
|
||||||
|
{Object.entries(dag.Env)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("\n")}
|
||||||
|
</Code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="sm">
|
||||||
|
Run History
|
||||||
|
</Title>
|
||||||
|
{runs?.length ? (
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Trigger</Table.Th>
|
||||||
|
<Table.Th>Started</Table.Th>
|
||||||
|
<Table.Th>Duration</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={r.ID}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/runs/${r.ID}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<StatusBadge status={r.Status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{r.Trigger}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{new Date(r.StartedAt).toLocaleString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{r.FinishedAt
|
||||||
|
? `${Math.round((new Date(r.FinishedAt).getTime() - new Date(r.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running..."}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No runs yet
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Title,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconRefresh,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { listDags, getSchedulerStatus, startScheduler, stopScheduler } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { DagSummary, SchedulerStatus } from "../types";
|
||||||
|
|
||||||
|
export function DagList() {
|
||||||
|
const [dags, setDags] = useState<DagSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [scheduler, setScheduler] = useState<SchedulerStatus | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [d, s] = await Promise.all([listDags(), getSchedulerStatus()]);
|
||||||
|
setDags(d || []);
|
||||||
|
setScheduler(s);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleScheduler = async () => {
|
||||||
|
if (scheduler?.running) {
|
||||||
|
await stopScheduler();
|
||||||
|
} else {
|
||||||
|
await startScheduler();
|
||||||
|
}
|
||||||
|
const s = await getSchedulerStatus();
|
||||||
|
setScheduler(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={2}>DAGs</Title>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconRefresh size={14} />}
|
||||||
|
onClick={load}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={scheduler?.running ? "filled" : "light"}
|
||||||
|
color={scheduler?.running ? "green" : "gray"}
|
||||||
|
leftSection={
|
||||||
|
scheduler?.running ? (
|
||||||
|
<IconPlayerStop size={14} />
|
||||||
|
) : (
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={toggleScheduler}
|
||||||
|
>
|
||||||
|
Scheduler {scheduler?.running ? "ON" : "OFF"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && <Alert color="red">{error}</Alert>}
|
||||||
|
|
||||||
|
{loading && !dags.length ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Schedule</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Tags</Table.Th>
|
||||||
|
<Table.Th>Last Status</Table.Th>
|
||||||
|
<Table.Th>Last Run</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{dags.map((d) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={d.file_path}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dags/${d.name}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500}>{d.name}</Text>
|
||||||
|
{d.description && (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{d.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" ff="monospace">
|
||||||
|
{d.schedule?.join(", ") || "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" size="xs">
|
||||||
|
{d.type || "chain"}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4}>
|
||||||
|
{d.tags?.map((t) => (
|
||||||
|
<Badge key={t} variant="dot" size="xs">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{d.last_run ? (
|
||||||
|
<StatusBadge status={d.last_run.Status} />
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
-
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">
|
||||||
|
{d.last_run
|
||||||
|
? new Date(d.last_run.StartedAt).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconArrowLeft } from "@tabler/icons-react";
|
||||||
|
import { getRun } from "../api";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { StepTimeline } from "../components/StepTimeline";
|
||||||
|
import type { RunDetail as RunDetailType } from "../types";
|
||||||
|
|
||||||
|
export function RunDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<RunDetailType | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
setData(await getRun(id));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Auto-refresh while running.
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (data?.run.Status === "running") {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [id, data?.run.Status]);
|
||||||
|
|
||||||
|
if (loading) return <Loader />;
|
||||||
|
if (error) return <Alert color="red">{error}</Alert>;
|
||||||
|
if (!data) return <Text>Not found</Text>;
|
||||||
|
|
||||||
|
const { run, steps } = data;
|
||||||
|
const duration = run.FinishedAt
|
||||||
|
? `${Math.round((new Date(run.FinishedAt).getTime() - new Date(run.StartedAt).getTime()) / 1000)}s`
|
||||||
|
: "running...";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate(`/dags/${run.DagName}`)}
|
||||||
|
>
|
||||||
|
Back to {run.DagName}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Run {run.ID.substring(0, 16)}...</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{run.DagName} · {run.Trigger} ·{" "}
|
||||||
|
{new Date(run.StartedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<StatusBadge status={run.Status} />
|
||||||
|
<Text size="sm">{duration}</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{run.Error && (
|
||||||
|
<Alert color="red" title="Error">
|
||||||
|
{run.Error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Steps ({steps?.length || 0})
|
||||||
|
</Title>
|
||||||
|
{steps?.length ? (
|
||||||
|
<StepTimeline steps={steps} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No steps recorded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface DagSummary {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
schedule?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
type?: string;
|
||||||
|
file_path: string;
|
||||||
|
valid: boolean;
|
||||||
|
last_run?: DagRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagRun {
|
||||||
|
ID: string;
|
||||||
|
DagName: string;
|
||||||
|
DagPath: string;
|
||||||
|
Status: string;
|
||||||
|
Trigger: string;
|
||||||
|
StartedAt: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagStepResult {
|
||||||
|
ID: string;
|
||||||
|
RunID: string;
|
||||||
|
StepName: string;
|
||||||
|
Status: string;
|
||||||
|
ExitCode: number;
|
||||||
|
Stdout: string;
|
||||||
|
Stderr: string;
|
||||||
|
StartedAt?: string;
|
||||||
|
FinishedAt?: string;
|
||||||
|
DurationMs: number;
|
||||||
|
Error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DagDetail {
|
||||||
|
info: DagSummary;
|
||||||
|
dag: {
|
||||||
|
Name: string;
|
||||||
|
Description: string;
|
||||||
|
Type: string;
|
||||||
|
Schedule: string[];
|
||||||
|
Steps: { Name: string; ID: string; Command: string; Script: string; Depends: string[] }[];
|
||||||
|
Env: Record<string, string>;
|
||||||
|
Tags: string[];
|
||||||
|
HandlerOn: { Failure: unknown[]; Success: unknown[] };
|
||||||
|
};
|
||||||
|
validation: {
|
||||||
|
Valid: boolean;
|
||||||
|
Errors: string[];
|
||||||
|
Warnings: string[];
|
||||||
|
Levels: string[][];
|
||||||
|
};
|
||||||
|
runs: DagRun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunDetail {
|
||||||
|
run: DagRun;
|
||||||
|
steps: DagStepResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStatus {
|
||||||
|
running: boolean;
|
||||||
|
dags: { name: string; path: string; schedule: string; next_run: string }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8090",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module dag-engine
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
fn-registry v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.3 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||||
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace fn-registry => /home/lucas/fn_registry
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||||
|
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||||
|
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||||
|
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||||
|
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||||
|
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||||
|
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||||
|
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||||
|
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||||
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||||
|
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
|
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||||
|
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListDags(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, dag, validation, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent runs.
|
||||||
|
runs, _, _ := executor.store.ListRuns(dag.Name, 10, 0)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"info": info,
|
||||||
|
"dag": dag,
|
||||||
|
"validation": validation,
|
||||||
|
"runs": runs,
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRunDag(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
info, _, _, err := executor.GetDAG(name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute asynchronously.
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
executor.ExecuteDAG(ctx, info.FilePath, "api")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Return run acknowledgment.
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||||
|
"status": "accepted",
|
||||||
|
"dag": name,
|
||||||
|
"message": "DAG execution started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleListRuns(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dagName := r.URL.Query().Get("dag")
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, total, err := executor.store.ListRuns(dagName, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"runs": runs,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetRun(executor *Executor) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
run, err := executor.store.GetRun(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := executor.store.ListStepResults(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"run": run,
|
||||||
|
"steps": steps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func handleSchedulerStart(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStop(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheduler.Stop()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSchedulerStatus(scheduler *Scheduler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := scheduler.Status()
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
iofs "io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
|
||||||
|
"dag-engine/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var frontendDist embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
args := os.Args[2:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "run":
|
||||||
|
cmdRun(args)
|
||||||
|
case "list":
|
||||||
|
cmdList(args)
|
||||||
|
case "status":
|
||||||
|
cmdStatus(args)
|
||||||
|
case "validate":
|
||||||
|
cmdValidate(args)
|
||||||
|
case "server":
|
||||||
|
cmdServer(args)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printUsage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Println(`dag-engine — DAG workflow executor
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dag-engine <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
run <path.yaml> Execute a DAG and show results
|
||||||
|
list [dir] List DAGs with schedule and last status
|
||||||
|
status [dag_name] Show execution history
|
||||||
|
validate <path.yaml> Parse and validate without executing
|
||||||
|
server Start HTTP server with web frontend
|
||||||
|
|
||||||
|
Server options:
|
||||||
|
--port <port> HTTP port (default: 8090)
|
||||||
|
--dags-dir <dir> DAGs directory (default: ~/dagu/dags)
|
||||||
|
--db <path> SQLite database path (default: dag_engine.db)
|
||||||
|
--scheduler Auto-start cron scheduler`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI Commands ---
|
||||||
|
|
||||||
|
func cmdRun(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine run <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dagPath := args[0]
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
// Parse optional flags after the path.
|
||||||
|
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, filepath.Dir(dagPath))
|
||||||
|
|
||||||
|
fmt.Printf("Executing %s...\n", dagPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
runID, err := executor.ExecuteDAG(ctx, dagPath, "manual")
|
||||||
|
|
||||||
|
// Print results.
|
||||||
|
if runID != "" {
|
||||||
|
run, _ := db.GetRun(runID)
|
||||||
|
steps, _ := db.ListStepResults(runID)
|
||||||
|
|
||||||
|
if run != nil {
|
||||||
|
fmt.Println()
|
||||||
|
for _, s := range steps {
|
||||||
|
icon := " "
|
||||||
|
switch s.Status {
|
||||||
|
case "success":
|
||||||
|
icon = "OK"
|
||||||
|
case "failed":
|
||||||
|
icon = "!!"
|
||||||
|
case "skipped":
|
||||||
|
icon = "--"
|
||||||
|
case "running":
|
||||||
|
icon = ".."
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s (%dms)\n", icon, s.StepName, s.DurationMs)
|
||||||
|
if s.Status == "failed" && s.Stderr != "" {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(s.Stderr), "\n") {
|
||||||
|
fmt.Printf(" %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
dur := ""
|
||||||
|
if run.FinishedAt != nil {
|
||||||
|
dur = fmt.Sprintf(" (%s)", run.FinishedAt.Sub(run.StartedAt).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
fmt.Printf("Run %s: %s%s\n", runID, strings.ToUpper(run.Status), dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdList(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||||
|
cfg.DagsDir = args[0]
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
dags, err := executor.ListDAGs()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list dags: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tSCHEDULE\tTYPE\tTAGS\tLAST STATUS\tLAST RUN")
|
||||||
|
for _, d := range dags {
|
||||||
|
sched := strings.Join(d.Schedule, ", ")
|
||||||
|
tags := strings.Join(d.Tags, ", ")
|
||||||
|
lastStatus := "-"
|
||||||
|
lastRun := "-"
|
||||||
|
if d.LastRun != nil {
|
||||||
|
lastStatus = d.LastRun.Status
|
||||||
|
lastRun = d.LastRun.StartedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
typ := d.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = "chain"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", d.Name, sched, typ, tags, lastStatus, lastRun)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdStatus(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
|
fs.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
||||||
|
limit := fs.Int("limit", 10, "number of runs to show")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
dagName := ""
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
dagName = fs.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
runs, total, err := db.ListRuns(dagName, *limit, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list runs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "Showing %d of %d runs", len(runs), total)
|
||||||
|
if dagName != "" {
|
||||||
|
fmt.Fprintf(w, " for %s", dagName)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
fmt.Fprintln(w, "RUN_ID\tDAG\tSTATUS\tTRIGGER\tSTARTED\tDURATION")
|
||||||
|
for _, r := range runs {
|
||||||
|
dur := "-"
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
dur = r.FinishedAt.Sub(r.StartedAt).Round(time.Millisecond).String()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
r.ID, r.DagName, r.Status, r.Trigger,
|
||||||
|
r.StartedAt.Format("2006-01-02 15:04:05"), dur)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdValidate(args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: dag-engine validate <path.yaml>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(args[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := core.DagValidate(dag)
|
||||||
|
|
||||||
|
fmt.Printf("DAG: %s\n", dag.Name)
|
||||||
|
fmt.Printf("Steps: %d\n", len(dag.Steps))
|
||||||
|
fmt.Printf("Schedule: %v\n", dag.Schedule)
|
||||||
|
fmt.Printf("Type: %s\n", dag.Type)
|
||||||
|
|
||||||
|
if result.Valid {
|
||||||
|
fmt.Println("Validation: PASS")
|
||||||
|
for i, level := range result.Levels {
|
||||||
|
fmt.Printf(" Level %d: %v\n", i, level)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("Validation: FAIL")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Printf(" ERROR: %s\n", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Printf(" WARNING: %s\n", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server Command ---
|
||||||
|
|
||||||
|
func cmdServer(args []string) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
cfg.ParseFlags(fs, args)
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
executor := NewExecutor(db, cfg.DagsDir)
|
||||||
|
scheduler := NewScheduler(executor, cfg.DagsDir)
|
||||||
|
|
||||||
|
// Prepare frontend FS.
|
||||||
|
var feFS iofs.FS
|
||||||
|
distFS, err := iofs.Sub(frontendDist, "frontend/dist")
|
||||||
|
if err == nil {
|
||||||
|
// Check if dist has content (built frontend exists).
|
||||||
|
entries, _ := iofs.ReadDir(distFS, ".")
|
||||||
|
if len(entries) > 0 {
|
||||||
|
feFS = distFS
|
||||||
|
log.Printf("serving frontend from embedded dist/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if feFS == nil {
|
||||||
|
log.Printf("no frontend build found, API-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterAPI(mux, executor, scheduler, feFS)
|
||||||
|
|
||||||
|
handler := corsMiddleware(loggingMiddleware(mux))
|
||||||
|
|
||||||
|
if cfg.AutoScheduler {
|
||||||
|
if err := scheduler.Start(); err != nil {
|
||||||
|
log.Printf("scheduler start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
log.Printf("dag-engine server starting on http://0.0.0.0%s", addr)
|
||||||
|
log.Printf("dags dir: %s", cfg.DagsDir)
|
||||||
|
log.Printf("database: %s", cfg.DBPath)
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: addr, Handler: handler}
|
||||||
|
|
||||||
|
// Graceful shutdown.
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("shutting down...")
|
||||||
|
scheduler.Stop()
|
||||||
|
srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// corsMiddleware adds permissive CORS headers for development.
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs each HTTP request with method, path and duration.
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fn-registry/functions/core"
|
||||||
|
"fn-registry/functions/infra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScheduledDAG represents a DAG with a parsed cron schedule.
|
||||||
|
type ScheduledDAG struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
NextRun time.Time `json:"next_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler manages cron-triggered DAG execution.
|
||||||
|
type Scheduler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dagsDir string
|
||||||
|
executor *Executor
|
||||||
|
dags []ScheduledDAG
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler creates a new scheduler.
|
||||||
|
func NewScheduler(executor *Executor, dagsDir string) *Scheduler {
|
||||||
|
return &Scheduler{
|
||||||
|
executor: executor,
|
||||||
|
dagsDir: dagsDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start scans for DAGs with schedules and starts cron tickers for each.
|
||||||
|
func (s *Scheduler) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("scheduler already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
s.running = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
scheduled, err := s.scanDAGs()
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.dags = scheduled
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[scheduler] started with %d DAGs", len(scheduled))
|
||||||
|
|
||||||
|
for _, dag := range scheduled {
|
||||||
|
dag := dag
|
||||||
|
go s.runTicker(ctx, dag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels all tickers and stops the scheduler.
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cancel()
|
||||||
|
s.running = false
|
||||||
|
s.dags = nil
|
||||||
|
log.Printf("[scheduler] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the scheduler is active.
|
||||||
|
func (s *Scheduler) IsRunning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the list of scheduled DAGs with their next run time.
|
||||||
|
type SchedulerStatus struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
DAGs []ScheduledDAG `json:"dags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Status() SchedulerStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return SchedulerStatus{
|
||||||
|
Running: s.running,
|
||||||
|
DAGs: s.dags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDAGs reads the dags directory and returns DAGs that have cron schedules.
|
||||||
|
func (s *Scheduler) scanDAGs() ([]ScheduledDAG, error) {
|
||||||
|
entries, err := os.ReadDir(s.dagsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduled []ScheduledDAG
|
||||||
|
for _, entry := range entries {
|
||||||
|
ext := filepath.Ext(entry.Name())
|
||||||
|
if ext != ".yaml" && ext != ".yml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.dagsDir, entry.Name())
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dag, err := core.DagParse(data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expr := range dag.Schedule {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(expr))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] invalid cron %q in %s: %v", expr, dag.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := core.NextCronTime(sched, time.Now())
|
||||||
|
scheduled = append(scheduled, ScheduledDAG{
|
||||||
|
Name: dag.Name,
|
||||||
|
Path: path,
|
||||||
|
Schedule: expr,
|
||||||
|
NextRun: next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTicker starts a cron ticker for a single DAG schedule.
|
||||||
|
func (s *Scheduler) runTicker(ctx context.Context, dag ScheduledDAG) {
|
||||||
|
sched, err := core.ParseCronExpr(strings.TrimSpace(dag.Schedule))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert core.CronSchedule to infra.CronTickerSchedule.
|
||||||
|
tickerSched := infra.CronTickerSchedule{
|
||||||
|
Minute: sched.Minute,
|
||||||
|
Hour: sched.Hour,
|
||||||
|
DayOfMonth: sched.DayOfMonth,
|
||||||
|
Month: sched.Month,
|
||||||
|
DayOfWeek: sched.DayOfWeek,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := infra.CronTicker(tickerSched, ctx)
|
||||||
|
log.Printf("[scheduler] ticker started for %s (%s), next: %s", dag.Name, dag.Schedule, dag.NextRun.Format(time.RFC3339))
|
||||||
|
|
||||||
|
for t := range ch {
|
||||||
|
log.Printf("[scheduler] triggered %s at %s", dag.Name, t.Format(time.RFC3339))
|
||||||
|
go func() {
|
||||||
|
runID, err := s.executor.ExecuteDAG(ctx, dag.Path, "cron")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[scheduler] %s failed: %v (run: %s)", dag.Name, err, runID)
|
||||||
|
} else {
|
||||||
|
log.Printf("[scheduler] %s completed (run: %s)", dag.Name, runID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS dag_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dag_name TEXT NOT NULL,
|
||||||
|
dag_path TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','cancelled')),
|
||||||
|
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual','cron','api')),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dag_step_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL REFERENCES dag_runs(id) ON DELETE CASCADE,
|
||||||
|
step_name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','success','failed','skipped')),
|
||||||
|
exit_code INTEGER NOT NULL DEFAULT -1,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_dag_name ON dag_runs(dag_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON dag_runs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_started ON dag_runs(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_step_results_run ON dag_step_results(run_id);
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/001_init.sql
|
||||||
|
var migrationSQL string
|
||||||
|
|
||||||
|
// DB wraps a SQLite connection for DAG run persistence.
|
||||||
|
type DB struct {
|
||||||
|
conn *sql.DB
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens or creates a DAG engine database at the given path.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(migrationSQL); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("store: migrate: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{conn: conn, path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagRun CRUD ---
|
||||||
|
|
||||||
|
// DagRun mirrors infra.DagRun for the store layer.
|
||||||
|
type DagRun struct {
|
||||||
|
ID string
|
||||||
|
DagName string
|
||||||
|
DagPath string
|
||||||
|
Status string
|
||||||
|
Trigger string
|
||||||
|
StartedAt time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRun inserts a new run record.
|
||||||
|
func (db *DB) CreateRun(run *DagRun) error {
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_runs (id, dag_name, dag_path, status, trigger, started_at, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
run.ID, run.DagName, run.DagPath, run.Status, run.Trigger,
|
||||||
|
run.StartedAt.Format(time.RFC3339), run.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRunStatus updates a run's status and optionally its finished_at and error.
|
||||||
|
func (db *DB) UpdateRunStatus(id, status string, finishedAt *time.Time, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_runs SET status=?, finished_at=?, error=? WHERE id=?`,
|
||||||
|
status, fin, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRun retrieves a single run by ID.
|
||||||
|
func (db *DB) GetRun(id string) (*DagRun, error) {
|
||||||
|
row := db.conn.QueryRow(
|
||||||
|
`SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error
|
||||||
|
FROM dag_runs WHERE id=?`, id,
|
||||||
|
)
|
||||||
|
return scanRun(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRuns returns runs, newest first, with optional dag name filter.
|
||||||
|
func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) {
|
||||||
|
var total int
|
||||||
|
var args []interface{}
|
||||||
|
where := ""
|
||||||
|
if dagName != "" {
|
||||||
|
where = " WHERE dag_name=?"
|
||||||
|
args = append(args, dagName)
|
||||||
|
}
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM dag_runs"+where, args...).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT id, dag_name, dag_path, status, trigger, started_at, finished_at, error FROM dag_runs" +
|
||||||
|
where + " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := db.conn.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var runs []DagRun
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanRunRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
runs = append(runs, *r)
|
||||||
|
}
|
||||||
|
return runs, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DagStepResult CRUD ---
|
||||||
|
|
||||||
|
// DagStepResult mirrors infra.DagStepResult for the store layer.
|
||||||
|
type DagStepResult struct {
|
||||||
|
ID string
|
||||||
|
RunID string
|
||||||
|
StepName string
|
||||||
|
Status string
|
||||||
|
ExitCode int
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
DurationMs int64
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertStepResult inserts a new step result.
|
||||||
|
func (db *DB) InsertStepResult(r *DagStepResult) error {
|
||||||
|
var startedAt, finishedAt *string
|
||||||
|
if r.StartedAt != nil {
|
||||||
|
s := r.StartedAt.Format(time.RFC3339)
|
||||||
|
startedAt = &s
|
||||||
|
}
|
||||||
|
if r.FinishedAt != nil {
|
||||||
|
s := r.FinishedAt.Format(time.RFC3339)
|
||||||
|
finishedAt = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`INSERT INTO dag_step_results (id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
r.ID, r.RunID, r.StepName, r.Status, r.ExitCode, r.Stdout, r.Stderr,
|
||||||
|
startedAt, finishedAt, r.DurationMs, r.Error,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStepResult updates a step result by ID.
|
||||||
|
func (db *DB) UpdateStepResult(id, status string, exitCode int, stdout, stderr string, finishedAt *time.Time, durationMs int64, errMsg string) error {
|
||||||
|
var fin *string
|
||||||
|
if finishedAt != nil {
|
||||||
|
s := finishedAt.Format(time.RFC3339)
|
||||||
|
fin = &s
|
||||||
|
}
|
||||||
|
_, err := db.conn.Exec(
|
||||||
|
`UPDATE dag_step_results SET status=?, exit_code=?, stdout=?, stderr=?, finished_at=?, duration_ms=?, error=? WHERE id=?`,
|
||||||
|
status, exitCode, stdout, stderr, fin, durationMs, errMsg, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStepResults returns all step results for a given run.
|
||||||
|
func (db *DB) ListStepResults(runID string) ([]DagStepResult, error) {
|
||||||
|
rows, err := db.conn.Query(
|
||||||
|
`SELECT id, run_id, step_name, status, exit_code, stdout, stderr, started_at, finished_at, duration_ms, error
|
||||||
|
FROM dag_step_results WHERE run_id=? ORDER BY started_at ASC`, runID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []DagStepResult
|
||||||
|
for rows.Next() {
|
||||||
|
var r DagStepResult
|
||||||
|
var startedAt, finishedAt sql.NullString
|
||||||
|
if err := rows.Scan(&r.ID, &r.RunID, &r.StepName, &r.Status, &r.ExitCode,
|
||||||
|
&r.Stdout, &r.Stderr, &startedAt, &finishedAt, &r.DurationMs, &r.Error); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if startedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, startedAt.String)
|
||||||
|
r.StartedAt = &t
|
||||||
|
}
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scan helpers ---
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRun(s scanner) (*DagRun, error) {
|
||||||
|
var r DagRun
|
||||||
|
var startedAt string
|
||||||
|
var finishedAt sql.NullString
|
||||||
|
if err := s.Scan(&r.ID, &r.DagName, &r.DagPath, &r.Status, &r.Trigger, &startedAt, &finishedAt, &r.Error); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.StartedAt, _ = time.Parse(time.RFC3339, startedAt)
|
||||||
|
if finishedAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, finishedAt.String)
|
||||||
|
r.FinishedAt = &t
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRunRows(rows *sql.Rows) (*DagRun, error) {
|
||||||
|
return scanRun(rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
---
|
||||||
|
name: shaders_lab
|
||||||
|
lang: cpp
|
||||||
|
domain: gfx
|
||||||
|
description: "Live GLSL fragment shader playground. Editor de codigo + node-editor visual (DAG con multi-source) + Functions palette drag-and-drop, dos canvas paralelos (Code y DAG), thumbnails per-nodo, todo nativo C++17 + ImGui + OpenGL 3.3 + imgui-node-editor."
|
||||||
|
tags: [gui, shaders, opengl, glsl, imgui, node-editor, dag, vj]
|
||||||
|
uses_functions:
|
||||||
|
- gl_loader_cpp_gfx
|
||||||
|
- gl_shader_cpp_gfx
|
||||||
|
- gl_framebuffer_cpp_gfx
|
||||||
|
- fullscreen_quad_cpp_gfx
|
||||||
|
- shader_canvas_cpp_gfx
|
||||||
|
- uniform_parser_cpp_gfx
|
||||||
|
- uniform_panel_cpp_gfx
|
||||||
|
- dag_catalog_cpp_gfx
|
||||||
|
- dag_compile_cpp_gfx
|
||||||
|
- dag_uniforms_cpp_gfx
|
||||||
|
- dag_palette_cpp_gfx
|
||||||
|
- dag_node_editor_cpp_gfx
|
||||||
|
- dag_node_previews_cpp_gfx
|
||||||
|
- shaderlab_db_cpp_gfx
|
||||||
|
- code_to_generator_cpp_gfx
|
||||||
|
- fps_overlay_cpp_core
|
||||||
|
- panel_menu_cpp_core
|
||||||
|
- layouts_menu_cpp_core
|
||||||
|
- app_menubar_cpp_core
|
||||||
|
- layout_storage_sqlite_cpp_core
|
||||||
|
uses_types: []
|
||||||
|
framework: "imgui + opengl3 + imgui-node-editor"
|
||||||
|
entry_point: "cpp/build/linux/apps/shaders_lab/shaders_lab"
|
||||||
|
dir_path: "cpp/apps/shaders_lab"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Descripcion
|
||||||
|
|
||||||
|
App de live-coding y composicion de fragment shaders GLSL con dos modos coexistentes: editor de codigo libre y editor de DAG visual con catalogo de nodos arrastrables. Pensada para jugar con shaders de tipo VJ y para extraer funciones GLSL al registry global.
|
||||||
|
|
||||||
|
## Estado actual
|
||||||
|
|
||||||
|
### Fase 1 — Core renderer + editor de codigo `[done]`
|
||||||
|
- Ventana ImGui + OpenGL 3.3 via `fn::run_app` del framework.
|
||||||
|
- Editor de texto del fragment shader con recompile auto (debounce 250 ms).
|
||||||
|
- Render a FBO + `ImGui::Image` para mostrar preview en panel propio.
|
||||||
|
- Errores de compilacion con linea, footer rojo en el panel Code.
|
||||||
|
- 3 presets seed: Plasma, Circle, Checker.
|
||||||
|
- Cross-compile a Windows con loader propio (`gl_loader`) — sin dependencias externas mas alla de GLFW/ImGui ya vendorizados.
|
||||||
|
|
||||||
|
### Fase 2 — Annotated uniforms + auto-controls `[done]`
|
||||||
|
- Parser de comentarios `// @slider`, `// @color`, `// @toggle`, `// @xy` sobre las declaraciones de uniforms.
|
||||||
|
- Panel `Controls` con widgets ImGui auto-generados (sliders, color pickers, checkboxes).
|
||||||
|
- Sincronizacion de valores entre recompilaciones por nombre del uniform.
|
||||||
|
- Tests inline para `uniform_parser` (6/6 asserts).
|
||||||
|
|
||||||
|
### Fase 3 — DAG mode `[done]`
|
||||||
|
- Catalogo de 11 nodos: 4 Gen (`solid`, `gradient`, `plasma`, `circle`), 3 Op (`invert`, `gamma`, `hueShift`), 3 Blend (`mix`, `multiply`, `screen`), 1 Output.
|
||||||
|
- Compilador `compile_dag_to_glsl(pipeline)` que emite un fragment shader unico con `vec4 node_<i>(...)` por nodo y main encadenando outputs.
|
||||||
|
- Multi-source: hasta 4 inputs por nodo via `source_ids[4]`. Compilador resuelve cada slot.
|
||||||
|
- Nodo `Output` (sink, rojo, no borrable): su `source_ids[0]` decide que va a `fragColor`.
|
||||||
|
- Tests inline para `dag_compile` (6/6) y `dag_catalog` (8/8).
|
||||||
|
|
||||||
|
### Fase 4a — Layout multi-ventana + dos canvas `[done]`
|
||||||
|
- Cada panel es ventana ImGui dockable independiente: `Code`, `DAG Pipeline`, `Canvas Code`, `Canvas DAG`, `Controls`, `Generated GLSL`, `Functions`.
|
||||||
|
- Dos `ShaderCanvas` simultaneos: el del Code y el del DAG renderizan en paralelo, cada uno con su FBO y programa propio.
|
||||||
|
- Sin focus-based recompile: cada fuente recompila solo cuando su contenido cambia.
|
||||||
|
|
||||||
|
### Fase 4b — Visual node editor (imgui-node-editor) `[done]`
|
||||||
|
- Vendorizada `imgui-node-editor` de thedmd en `cpp/vendor/imgui-node-editor/` (parche puntual en `imgui_extra_math.inl` para evitar choque con ImGui 1.92.7).
|
||||||
|
- Layout 3 columnas por nodo: pines input a la izquierda, controles en el centro, pin output a la derecha.
|
||||||
|
- Pines como circulos de radio 9 pegados al borde del nodo (mitad fuera, mitad dentro), color uniforme neutro (data type uniforme = `vec4`).
|
||||||
|
- `ed::PinRect` cubre el circulo entero — la mitad sobresaliente sigue siendo grabbable.
|
||||||
|
- Cables 2.5px del color del pin.
|
||||||
|
- Node drag, pan, zoom — todo nativo del editor.
|
||||||
|
- Topology change disparado solo cuando se anaden/quitan/reconectan nodos. Mover sliders no recompila.
|
||||||
|
|
||||||
|
### Fase 4c — Functions palette drag-drop `[done]`
|
||||||
|
- Ventana `Functions` con catalogo agrupado en `Generators / Operators / Blends`.
|
||||||
|
- Cada item es drag source con payload `DAG_NODE_TYPE`.
|
||||||
|
- Drop sobre el canvas del DAG anade el nodo en la posicion del mouse.
|
||||||
|
- Sin botones `+ Add Node` / `Clear` — todo flujo via drag-drop.
|
||||||
|
- Output node nunca aparece en la paleta (sink unico fijo).
|
||||||
|
|
||||||
|
### Fase 4d — UX deletes + cycle check real `[done]`
|
||||||
|
- **Right-click sobre cable**: borra ese link.
|
||||||
|
- **Right-click sobre pin output**: limpia el fan-out completo (todos los inputs que apuntaban a este nodo).
|
||||||
|
- **Right-click sobre pin input**: limpia ese slot.
|
||||||
|
- **Doble right-click sobre nodo**: borra el nodo (Output protegido).
|
||||||
|
- Validacion de ciclo via DFS sobre `source_ids` (no por indice del vector); `topo_sort` reordena el pipeline tras cada cambio para mantener `out_<i>` coherentes.
|
||||||
|
- Drop de nuevo nodo se inserta antes del Output, no al final.
|
||||||
|
|
||||||
|
### Fase 4e — Per-node preview `[done]`
|
||||||
|
- Toggle `[+] preview` / `[-] preview` en cada nodo no-Output (off por defecto).
|
||||||
|
- Cada nodo abierto tiene su FBO de 96x64 keyed por `editor_uid`.
|
||||||
|
- Compilador emite `uniform int u_preview_target` y branches `if (u_preview_target == i) { fragColor = out_i; return; }`.
|
||||||
|
- `dag_previews_render` itera nodos con preview abierto, dibuja al FBO con ese index.
|
||||||
|
- Sin recompile al togglear preview ni al mover sliders — un solo programa GL.
|
||||||
|
|
||||||
|
### Fase 5 — SQLite + custom generators desde el Code `[done]`
|
||||||
|
- **`u_params` a tamaño dinámico**: array global `vec4 u_params[64]` (256 floats), cada nodo ocupa `ceil(param_count/4)` vec4s consecutivos. `dag_param_layout(pipeline)` calcula el indice base por nodo; compilador y `dag_uniforms_apply` lo comparten. `DagStep::params` y `DagNodeDef::param_*` pasan a `vector<>`.
|
||||||
|
- **Nuevos Gen nodes (8)**: `checker`, `stripes`, `dots`, `rings`, `polar_rays`, `noise_value`, `voronoi`, `truchet`. Catalogo total: 19 nodos (4 originales + 8 nuevos Gen + 4 Op + 3 Blend + Output).
|
||||||
|
- **Bug fix `solid`**: el control Color con `ImGuiColorEditFlags_NoLabel` no mostraba el nombre. Ahora `dag_node_editor` imprime `TextUnformatted(label) + SameLine` antes del swatch.
|
||||||
|
- **Persistencia `shaders_lab.db`** (SQLite local en `apps/shaders_lab/shaders_lab.db`): tabla `generators` con `id, label, description, source_glsl, body_glsl, param_count, param_defaults, param_names, controls, tags, timestamps`. Funcion `shaderlab_db` (CRUD) testeada (7/7) y reutilizable.
|
||||||
|
- **Catalogo mutable**: `dag_register_node()` / `dag_unregister_node()`. Built-ins protegidos via flag `is_builtin`.
|
||||||
|
- **Code → Generator**: funcion pura `code_to_generator(source)` traduce el GLSL del Code en un body de Gen + DagControl[] (testeada 7/7). Cada uniform anotado se convierte en su control (slider/xy/color); cada uniform reclama 1 vec4 entero. El body se transforma asi: lineas `vec2 uv = ...` eliminadas, `fragColor = X;` -> `return X;`, locales `<type> <name> = u_params[__BASE__+i].swizzle;` prependidas. La lambda `body_glsl` substituye `__BASE__` con el indice runtime.
|
||||||
|
- **UI**: boton `Save as generator...` en el panel `Code` con modal (name snake_case + label + description + tags). Tras guardar, el nodo aparece en la paleta `Functions`. Al arrancar, `load_user_generators_into_catalog()` re-traduce y registra los persistidos.
|
||||||
|
- **Quitados**: botones de presets `Plasma / Circle / Checker` y el archivo `seed_shaders.h`. Default del Code = un placeholder con uniforms anotados como ejemplo.
|
||||||
|
|
||||||
|
### Fase 6 — Menubar reusable (View + Layouts) `[done]`
|
||||||
|
|
||||||
|
App estrena una `BeginMainMenuBar` con dos menus, cableada via `app_menubar_cpp_core`:
|
||||||
|
|
||||||
|
- **View** (`panel_menu_cpp_core`): MenuItem checkable por cada uno de los 7 paneles (`Code`, `DAG Pipeline`, `Canvas Code`, `Canvas DAG`, `Controls`, `Functions`, `Generated GLSL`). Cada bool `g_show_*` se comparte con el `bool*` de `ImGui::Begin(name, &g_show_X)`, asi que la X de cada ventana sincroniza con el menu. Cada `Begin/End` envuelto en guard para no llamar `End` si el panel esta oculto.
|
||||||
|
|
||||||
|
- **Layouts** (`layouts_menu_cpp_core`): captura del layout actual de ImGui (`SaveIniSettingsToMemory`) bajo un nombre, persistido en la tabla `ui_layouts(name, blob, created_at, updated_at)` de `shaders_lab.db`. Items:
|
||||||
|
- Lista de layouts guardados (click → apply, marker `* ` en el activo).
|
||||||
|
- `Save current as...` (popup con InputText).
|
||||||
|
- `Delete` (submenu listando los layouts).
|
||||||
|
- `Reset to default` (abre todos los paneles, limpia marker activo).
|
||||||
|
|
||||||
|
Detalles tecnicos:
|
||||||
|
- `LoadIniSettingsFromMemory` se difiere al inicio del frame siguiente via `g_pending_layout_blob` (no se puede llamar mid-frame entre `NewFrame` y `Render`).
|
||||||
|
- `shaders_lab.db` se reutiliza para `ui_layouts` via nuevo getter `shaderlab_db_handle()` — una sola conexion SQLite para generators y layouts.
|
||||||
|
- Las callbacks (`list/on_apply/on_save/on_delete/on_reset`) se cablean en `main()` con lambdas que envuelven las primitivas CRUD de `layout_storage_sqlite_cpp_core`.
|
||||||
|
|
||||||
|
### Como usarlo en otras apps
|
||||||
|
|
||||||
|
Patron reusable de tres pasos:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "core/app_menubar.h"
|
||||||
|
#include "core/layout_storage_sqlite.h"
|
||||||
|
|
||||||
|
// 1. Declarar bools de visibilidad por panel
|
||||||
|
static bool g_show_foo = true;
|
||||||
|
static bool g_show_bar = true;
|
||||||
|
|
||||||
|
// 2. Declarar callbacks y blob diferido
|
||||||
|
static fn_ui::LayoutCallbacks g_layout_cb;
|
||||||
|
static std::string g_pending_blob;
|
||||||
|
static std::string g_pending_name;
|
||||||
|
|
||||||
|
// 3. En main(), cablear callbacks contra tu sqlite3*
|
||||||
|
fn_ui::layout_storage_init(db);
|
||||||
|
g_layout_cb.list = [db]{ return fn_ui::layout_storage_list(db); };
|
||||||
|
g_layout_cb.on_apply = [db](const std::string& n) {
|
||||||
|
g_pending_blob = fn_ui::layout_storage_load_blob(db, n);
|
||||||
|
g_pending_name = n;
|
||||||
|
};
|
||||||
|
g_layout_cb.on_save = [db](const std::string& n) {
|
||||||
|
size_t sz = 0;
|
||||||
|
const char* b = ImGui::SaveIniSettingsToMemory(&sz);
|
||||||
|
if (b && sz) fn_ui::layout_storage_save(db, n, std::string(b, sz));
|
||||||
|
g_layout_cb.active_name = n;
|
||||||
|
};
|
||||||
|
g_layout_cb.on_delete = [db](const std::string& n) {
|
||||||
|
fn_ui::layout_storage_delete(db, n);
|
||||||
|
if (g_layout_cb.active_name == n) g_layout_cb.active_name.clear();
|
||||||
|
};
|
||||||
|
g_layout_cb.on_reset = []{ /* abrir todos los paneles, limpiar active_name */ };
|
||||||
|
|
||||||
|
// 4. En render(), aplicar pendientes y llamar app_menubar
|
||||||
|
void render() {
|
||||||
|
if (!g_pending_blob.empty()) {
|
||||||
|
ImGui::LoadIniSettingsFromMemory(g_pending_blob.c_str(), g_pending_blob.size());
|
||||||
|
g_layout_cb.active_name = g_pending_name;
|
||||||
|
g_pending_blob.clear(); g_pending_name.clear();
|
||||||
|
}
|
||||||
|
ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport());
|
||||||
|
|
||||||
|
fn_ui::PanelToggle toggles[] = {
|
||||||
|
{"Foo", "Ctrl+1", &g_show_foo},
|
||||||
|
{"Bar", "Ctrl+2", &g_show_bar},
|
||||||
|
};
|
||||||
|
fn_ui::app_menubar(toggles, std::size(toggles), &g_layout_cb);
|
||||||
|
|
||||||
|
if (g_show_foo) {
|
||||||
|
if (ImGui::Begin("Foo", &g_show_foo)) { /* ... */ }
|
||||||
|
ImGui::End();
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 7 — UX node editor + DAG correctness `[done]` (2026-04-25)
|
||||||
|
|
||||||
|
Pulido de la edición visual del DAG y corrección de fugas en el render. Sin cambio de schema ni de catalog público (más allá del `dag_register_node` ya añadido en Fase 5).
|
||||||
|
|
||||||
|
- **Nodos más grandes para conectar más rápido** (`dag_node_editor.cpp`):
|
||||||
|
- `PIN_RADIUS` 9 → **14 px** (área de grab ~2.5×). `PIN_DIAMETER`, `CABLE_THICK` 2.5 → **3.5**, borde de pin 1.5 → 2.0.
|
||||||
|
- `CONTROL_WIDTH` constante 150 → **220 px**, `COL_GAP` 8 → **14 px**, `NodePadding` vertical 8 → 12.
|
||||||
|
- Espaciado inicial entre nodos auto-colocados 220 → 320 px.
|
||||||
|
- **Bug fix `solid` sin label**: el control `Color` usaba `ImGuiColorEditFlags_NoLabel`, así que el swatch era el único contenido del nodo y parecía "sin nombre ni parámetro". Fix en `dag_node_editor.cpp`: imprimir `ImGui::TextUnformatted(ctrl.label) + SameLine` antes del swatch. Aplica a todo control de tipo `Color`, no solo a `solid`.
|
||||||
|
- **Strict output** (`dag_compile.cpp`): eliminado el fallback `last_valid_out` que filtraba el output del último nodo evaluado cuando `Output` no tenía source o no existía. Ahora la regla es: solo se emite lo conectado al nodo `Output`; en cualquier otro caso `seed()` (gris oscuro `vec4(0.04, 0.04, 0.06, 1.0)`). El `resolve()` de inputs internos también dejó de caer a `last_valid_out` y ahora emite `vec4(0,0,0,1)` para slots sin conectar. Tests: `dag_compile` 6/6 → **7/7** (test 4b verifica que el seed final aparece después de las branches de preview, no antes).
|
||||||
|
- **Generated GLSL autocontenido** (`compile_dag_to_glsl_baked`, nuevo en `dag_compile.{h,cpp}`):
|
||||||
|
- Sustituye `uniform vec4 u_params[64]` por `const vec4 u_params[N] = vec4[N](vec4(...), ...)` con los valores actuales del pipeline empaquetados (mismo layout que `dag_uniforms_apply`).
|
||||||
|
- Sustituye `uniform int u_preview_target` por `const int u_preview_target = -1` (las branches de preview quedan muertas y el GLSL compiler las elimina).
|
||||||
|
- Resultado: el shader del panel `Generated GLSL` no depende de ningún uniform externo. Pegarlo en el editor `Code` reproduce exactamente el render del DAG en el momento del copy. Después editar el DAG no afecta al Code.
|
||||||
|
- Test 7 nuevo: `dag_compile_baked` no contiene `uniform vec4 u_params` ni `uniform int u_preview_target`, sí contiene `const vec4 u_params[` y los valores empaquetados.
|
||||||
|
- **Importante**: el `Canvas Code` ya NO recibe `dag_uniforms_apply`. Es totalmente independiente. (Versión anterior intentaba sincronizarlos; rompía el aislamiento entre paneles.)
|
||||||
|
- **`dag_uniforms_apply` también resetea `u_preview_target = -1`** al final, para que la rama de preview quede desactivada en el render principal del Canvas DAG. La rutina `dag_previews_render` la activa de forma transitoria por nodo y la deja restaurada.
|
||||||
|
- **Drop-replace del mismo kind**:
|
||||||
|
- Soltar un nodo de la paleta sobre un nodo existente del **mismo `DagKind`** (Gen sobre Gen, Op sobre Op, Blend sobre Blend, nunca sobre Output) sustituye `name`+`params`+`controls` conservando `id`, `editor_uid`, `editor_pos_x/y`, `source_ids[]` y `preview_open`.
|
||||||
|
- Slots de input que sobran (si el nuevo def tiene menos `num_inputs` que el anterior) se limpian.
|
||||||
|
- Hit-test contra cajas de nodos vía `ed::GetNodePosition` + `ed::GetNodeSize` (canvas-space). No se usa `ed::GetHoveredNode()` porque no es fiable durante un drag-drop activo.
|
||||||
|
- **Drop-on-cable splice (intercalar nodo)**:
|
||||||
|
- Soltar un nodo de la paleta **o** arrastrar un nodo Op/Blend ya existente sobre un cable: el nodo se inserta entre `src` y `dst`. `new.source_ids[0] = src.id`, `dst.source_ids[slot] = new.id`. Para Blend (2 inputs), slot 0 queda cableado y slot 1 vacío.
|
||||||
|
- Para nodos existentes movidos: además de las dos rewires anteriores, se limpian todas las refs hacia el nodo movido en otros `source_ids[]` antes (lo desengancha de cualquier consumidor previo, queda exclusivamente en la nueva posición). Tracking del nodo arrastrado vía `s_drag_existing_uid` (set en `IsMouseClicked(0)` cuando hay un nodo hovered y no hay pin hovered, def es Gen/Op/Blend, no Output).
|
||||||
|
- Hit-test del cable: distancia punto-segmento (`dist_point_to_segment`) entre el cursor y la línea aproximada `(src.right_mid → dst.left_at_slot_k)`. Threshold **18 px** canvas-space.
|
||||||
|
- Prioridad: cable-hit > node-hit > add-vacío.
|
||||||
|
- **Splice highlight (preview visual)**:
|
||||||
|
- Mientras hay un drag activo de paleta o de nodo del canvas, el cable candidato se redibuja en `SPLICE_COLOR = (1.00, 0.82, 0.18, 1)` (dorado) más grueso (`CABLE_THICK + 2`).
|
||||||
|
- **Garantía visual**: además de cambiar el color en `ed::Link()`, se dibuja un bezier dorado encima en el `ImGui::GetForegroundDrawList()` (canvas → screen via `ed::CanvasToScreen`). Esto evita problemas de compositing interno del editor que podían enterrar el cambio de color.
|
||||||
|
- Detección sin gates: la versión anterior gateaba con `IsMouseDown` + `window_hovered`, lo que silenciaba el highlight. Ahora basta con la presencia del payload de drag-drop (paleta) o del `s_drag_existing_uid` (nodo del canvas).
|
||||||
|
- **Catalog `dag_catalog.cpp`** ya soporta `is_builtin` (Fase 5) y permite `dag_register_node` / `dag_unregister_node` para generators custom; el splice/replace funciona sobre todos por igual (Built-ins, Gen custom guardados desde Code).
|
||||||
|
|
||||||
|
Comandos:
|
||||||
|
```bash
|
||||||
|
# Build linux
|
||||||
|
./fn run build_cpp_linux_bash_infra shaders_lab
|
||||||
|
|
||||||
|
# Build windows (cross-compile)
|
||||||
|
./fn run build_cpp_windows_bash_infra shaders_lab
|
||||||
|
|
||||||
|
# Tests del dominio gfx (puros, sin GL)
|
||||||
|
g++ -std=c++17 -Icpp/functions -DDAG_CATALOG_TEST cpp/functions/gfx/dag_catalog.cpp -o /tmp/dag_catalog_test && /tmp/dag_catalog_test
|
||||||
|
g++ -std=c++17 -Icpp/functions -DDAG_COMPILE_TEST cpp/functions/gfx/dag_compile.cpp cpp/functions/gfx/dag_catalog.cpp -o /tmp/dag_compile_test && /tmp/dag_compile_test
|
||||||
|
g++ -std=c++17 -Icpp/functions -DCODE_TO_GENERATOR_TEST cpp/functions/gfx/code_to_generator.cpp cpp/functions/gfx/uniform_parser.cpp -o /tmp/code_to_generator_test && /tmp/code_to_generator_test
|
||||||
|
g++ -std=c++17 -Icpp/functions -DUNIFORM_PARSER_TEST cpp/functions/gfx/uniform_parser.cpp -o /tmp/uniform_parser_test && /tmp/uniform_parser_test
|
||||||
|
gcc -c -O2 -DSQLITE_THREADSAFE=1 cpp/vendor/sqlite3/sqlite3.c -o /tmp/sqlite3.o && \
|
||||||
|
g++ -std=c++17 -Icpp/functions -Icpp/vendor/sqlite3 -DSHADERLAB_DB_TEST cpp/functions/gfx/shaderlab_db.cpp /tmp/sqlite3.o -lpthread -ldl -o /tmp/shaderlab_db_test && /tmp/shaderlab_db_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Cobertura de tests inline tras esta fase: **8 + 7 + 7 + 6 + 7 = 35 asserts** sobre `dag_catalog` (19 nodos), `dag_compile` (strict + baked), `code_to_generator`, `uniform_parser`, `shaderlab_db`.
|
||||||
|
|
||||||
|
Sync de binarios Windows (regla establecida en esta sesión):
|
||||||
|
- `cpp/build/windows/apps/shaders_lab/shaders_lab.exe` (origen)
|
||||||
|
- `apps/shaders_lab/shaders_lab.exe` (in-repo)
|
||||||
|
- `/mnt/c/Users/lucas/Desktop/shaders_lab.exe` (Windows Desktop)
|
||||||
|
- **NUNCA** copiar a `/mnt/c/Users/AdminLocal/`. Memoria persistente: `feedback_no_adminlocal.md`.
|
||||||
|
|
||||||
|
## Lo siguiente que pega
|
||||||
|
|
||||||
|
- Push selectivo al registry global: boton `Push to registry` que extrae el generator a `cpp/functions/gfx/<name>.{cpp,md}` con tag `shaders_lab` y dispara `fn index`.
|
||||||
|
- Listado / borrado de generators custom desde la UI (hoy solo via DB directa).
|
||||||
|
- Persistencia de pipelines con nombre.
|
||||||
|
- Mas nodos: warps (twirl, polar, kaleidoscope), perlin/fbm reales, SDFs, filtros de luma.
|
||||||
|
- Save as Op (1 input `a`) y Save as Blend (2 inputs).
|
||||||
|
- Crossfade A↔B: tercer canvas que mezcla Canvas Code y Canvas DAG con un slider.
|
||||||
|
- Cliente Claude: chat con tool use (`search_registry`, `apply_shader`, `save_function`).
|
||||||
|
- Integracion VJ: Spout/Syphon/NDI para mandar el output a Resolume/OBS.
|
||||||
|
|
||||||
|
Documentacion de exploraciones aparcadas (no en backlog inmediato):
|
||||||
|
- `NEXT_STEPS_DATA_TYPES.md` — extensiones del DAG: pins tipados, texturas, SDF/raymarch, multi-pass, geometria 3D.
|
||||||
|
- `NEXT_STEPS_BORDERLESS_WINDOW.md` — quitar la titlebar del SO y mover min/max/close al `MainMenuBar` ImGui.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run build_cpp_linux_bash_infra shaders_lab
|
||||||
|
./fn run build_cpp_windows_bash_infra shaders_lab
|
||||||
|
```
|
||||||
|
|
||||||
|
- Linux: `cpp/build/linux/apps/shaders_lab/shaders_lab`
|
||||||
|
- Windows (cross-compile mingw-w64): `cpp/build/windows/apps/shaders_lab/shaders_lab.exe` — copiado a `apps/shaders_lab/shaders_lab.exe` y al Desktop tras cada build.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
g++ -std=c++17 -Icpp/functions -DUNIFORM_PARSER_TEST cpp/functions/gfx/uniform_parser.cpp -o /tmp/uniform_parser_test && /tmp/uniform_parser_test
|
||||||
|
g++ -std=c++17 -Icpp/functions -DDAG_COMPILE_TEST cpp/functions/gfx/dag_compile.cpp cpp/functions/gfx/dag_catalog.cpp -o /tmp/dag_compile_test && /tmp/dag_compile_test
|
||||||
|
g++ -std=c++17 -Icpp/functions -DDAG_CATALOG_TEST cpp/functions/gfx/dag_catalog.cpp -o /tmp/dag_catalog_test && /tmp/dag_catalog_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Cobertura actual: 6 + 6 + 8 = 20 asserts puros (sin GL/ImGui). La parte UI (`dag_node_editor`, `dag_palette`, `uniform_panel`, `shader_canvas`) no es testeable sin entorno grafico.
|
||||||
|
|
||||||
|
## Uniforms del Code mode
|
||||||
|
|
||||||
|
Auto-prependidos por `compile_fragment`:
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform float u_time;
|
||||||
|
uniform vec2 u_mouse;
|
||||||
|
out vec4 fragColor;
|
||||||
|
```
|
||||||
|
|
||||||
|
El cuerpo del fragment se escribe sin `#version`, sin `out`, sin esos uniforms. Cualquier `uniform` adicional declarado por el usuario con anotacion (`// @slider`, etc.) genera un widget en `Controls`.
|
||||||
|
|
||||||
|
## Uniforms del DAG mode
|
||||||
|
|
||||||
|
Ademas de los anteriores, el shader generado por `compile_dag_to_glsl` declara:
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
uniform vec4 u_params[16]; // 4 floats por nodo (slot del nodo i en u_params[i])
|
||||||
|
uniform int u_preview_target; // -1 = real Output; 0..15 = render out_<i>
|
||||||
|
```
|
||||||
|
|
||||||
|
`dag_uniforms_apply` sube `u_params[16]` cada frame antes del draw del Canvas DAG. `dag_previews_render` rebinde el FBO de cada nodo abierto y setea `u_preview_target` antes de cada draw.
|
||||||
|
|
||||||
|
## Layouts
|
||||||
|
|
||||||
|
ImGui persiste el layout actual en `imgui.ini` junto al binario (autosave). Ademas, el menu **Layouts** permite tener varios layouts guardados con nombre:
|
||||||
|
|
||||||
|
- Mueve los paneles donde quieras.
|
||||||
|
- `Layouts > Save current as...` y dale un nombre (ej. "Coding", "DAG mode", "Showcase").
|
||||||
|
- Cambia el layout, guarda otro.
|
||||||
|
- `Layouts > <nombre>` para saltar; el activo se marca con `* `.
|
||||||
|
- `Layouts > Delete > <nombre>` para borrar.
|
||||||
|
- `Layouts > Reset to default` reabre todos los paneles y limpia el marker.
|
||||||
|
|
||||||
|
Los layouts guardados viven en la tabla `ui_layouts` de `shaders_lab.db`.
|
||||||
|
|
||||||
|
Disposicion comoda al primer arranque:
|
||||||
|
- `Code` y `DAG Pipeline` ocupan la fila superior.
|
||||||
|
- `Canvas Code` y `Canvas DAG` ocupan la fila inferior, lado a lado.
|
||||||
|
- `Functions` y `Controls` van a un lateral.
|
||||||
|
- `Generated GLSL` minimizado o en pestana junto a `Controls`.
|
||||||
|
|
||||||
|
El menu **View** togglea cada panel individualmente (mismo `bool*` que la X de la ventana).
|
||||||
|
|
||||||
|
## Notas de cross-compile
|
||||||
|
|
||||||
|
- `gl_loader` resuelve simbolos OpenGL 2.0+ con `wglGetProcAddress` en Windows; en Linux es no-op (`GL_GLEXT_PROTOTYPES`).
|
||||||
|
- `WIN32_EXECUTABLE TRUE` en `CMakeLists.txt` evita la consola al lanzar el .exe.
|
||||||
|
- Vendor de imgui-node-editor cuesta ~1MB en el binario final (~18 MB total).
|
||||||
|
|
||||||
|
## Notas — Settings + iconos (sesion 2026-04-25)
|
||||||
|
|
||||||
|
- `app_menubar` ahora añade automaticamente un tercer item `Settings...` junto a `View` y `Layouts`. Click abre la ventana flotante de `app_settings` (Display: toggle FPS overlay; Typography: combo de fuente Karla/Roboto/DroidSans/Cousine + slider de tamaño 10..32 px). Persiste en `app_settings.ini` junto a `shaders_lab.exe`.
|
||||||
|
- Defaults: DroidSans 15 px, FPS overlay off (antes hardcoded ON dentro del panel `Controls`).
|
||||||
|
- Removida la llamada explicita `fps_overlay()` del panel `Controls` — ahora se respeta el toggle de Settings.
|
||||||
|
- Removidos los `.cpp` de `fps_overlay`, `panel_menu`, `layouts_menu`, `app_menubar` del `CMakeLists.txt` — viven en `fn_framework` para evitar multiple-definition. Solo `layout_storage_sqlite.cpp` sigue listado explicitamente.
|
||||||
|
- 5 TTFs (Karla / Roboto / DroidSans / Cousine / Tabler) copiadas junto al exe via `add_imgui_app` post-build.
|
||||||
|
|
||||||
|
Para añadir secciones propias de settings:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// En main.cpp antes de fn::run_app:
|
||||||
|
fn_ui::settings_window_add_section("shader_compiler", "Shader compiler", []{
|
||||||
|
ImGui::Checkbox("Auto-compile on save", &g_auto_compile);
|
||||||
|
ImGui::SliderInt("Debounce (ms)", &g_debounce_ms, 50, 2000);
|
||||||
|
});
|
||||||
|
// Aparece debajo de Display/Typography. Persistencia propia (puede ir en
|
||||||
|
// shaders_lab.db, tabla ui_settings).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lo siguiente que pega
|
||||||
|
|
||||||
|
- Ejemplo concreto de seccion extra de settings: `auto-compile on save` + `debounce_ms` registrados desde `main.cpp` y persistidos en una tabla `ui_settings` en `shaders_lab.db`.
|
||||||
|
- Auditar hex UTF-8 (`"\x..\x.."`) o emojis Unicode hardcoded en uniform_panel, dag_panel, dag_node_editor → migrar a `TI_*` de `core/icons_tabler.h`.
|
||||||
|
- Rebuild Windows + sync: `cmake --build cpp/build/windows --target shaders_lab && cp cpp/build/windows/apps/shaders_lab/{shaders_lab.exe,*.ttf} /mnt/c/Users/lucas/Desktop/apps/shaders_lab/`.
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: analyze_dns
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "analyze_dns(domain: string, mode: string) -> void"
|
||||||
|
description: "Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA, consulta whois y verificación contra listas negras DNSBL (spamhaus, spamcop, sorbs, barracuda)."
|
||||||
|
tags: [bash, cybersecurity, dns, network, whois, dnsbl, reconnaissance]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: domain
|
||||||
|
desc: "dominio a analizar, ej: example.com"
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de análisis: records (solo registros DNS), whois (solo whois), dnsbl (solo listas negras) o all (todo, por defecto)"
|
||||||
|
output: "imprime registros DNS, información whois y estado DNSBL a stdout con colores ANSI"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/analyze_dns.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/analisis_dns.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/analyze_dns.sh
|
||||||
|
|
||||||
|
# Análisis completo
|
||||||
|
analyze_dns example.com
|
||||||
|
|
||||||
|
# Solo registros DNS
|
||||||
|
analyze_dns example.com records
|
||||||
|
|
||||||
|
# Solo whois
|
||||||
|
analyze_dns example.com whois
|
||||||
|
|
||||||
|
# Solo DNSBL
|
||||||
|
analyze_dns example.com dnsbl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `dig` (paquete dnsutils). `whois` es opcional — si no está instalado y el modo es `all`, se omite el paso whois con aviso. Las listas negras DNSBL se consultan via DNS inverso (técnica estándar sin HTTP). El modo `dnsbl` resuelve primero la IP del dominio y luego construye la consulta invertida para cada blacklist.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# analyze_dns
|
||||||
|
# -----------
|
||||||
|
# Análisis DNS completo de un dominio: registros A/AAAA/MX/NS/TXT/CNAME/SOA,
|
||||||
|
# consulta whois y verificación contra listas negras DNSBL.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# analyze_dns example.com [records|whois|dnsbl|all]
|
||||||
|
#
|
||||||
|
# Depende de: dig, whois (opcional), curl (para DNSBL)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_dns_is_valid_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
[[ -n "$domain" && "$domain" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_build_dnsbl_query() {
|
||||||
|
local ip="$1"
|
||||||
|
local bl="$2"
|
||||||
|
local reversed
|
||||||
|
reversed="$(echo "$ip" | awk -F. '{print $4"."$3"."$2"."$1}')"
|
||||||
|
echo "${reversed}.${bl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_dns_query_record() {
|
||||||
|
local domain="$1"
|
||||||
|
local type="$2"
|
||||||
|
local result
|
||||||
|
result="$(dig +short "$type" "$domain" 2>/dev/null || true)"
|
||||||
|
if [[ -z "$result" ]]; then
|
||||||
|
echo " (sin registros)"
|
||||||
|
else
|
||||||
|
echo "$result" | while IFS= read -r line; do
|
||||||
|
echo " * $line"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_show_all_records() {
|
||||||
|
local domain="$1"
|
||||||
|
echo ""
|
||||||
|
for type in A AAAA MX NS TXT CNAME SOA; do
|
||||||
|
echo -e "${CYAN}── ${type} ──────────────────${NC}"
|
||||||
|
_dns_query_record "$domain" "$type"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_show_whois() {
|
||||||
|
local domain="$1"
|
||||||
|
echo ""
|
||||||
|
info "Consultando whois de ${domain}..."
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
whois "$domain" 2>/dev/null \
|
||||||
|
| grep -iE "(registrar|registrant|creation|expiry|expire|updated|name server|status)" \
|
||||||
|
| head -20 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_dns_check_dnsbl() {
|
||||||
|
local domain="$1"
|
||||||
|
local ip
|
||||||
|
ip="$(dig +short A "$domain" 2>/dev/null | head -1 || true)"
|
||||||
|
|
||||||
|
if [[ -z "$ip" ]]; then
|
||||||
|
warning "No se pudo resolver la IP de $domain para comprobar DNSBL"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "IP a comprobar: $ip"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local blacklists=(
|
||||||
|
"zen.spamhaus.org"
|
||||||
|
"bl.spamcop.net"
|
||||||
|
"dnsbl.sorbs.net"
|
||||||
|
"b.barracudacentral.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
for bl in "${blacklists[@]}"; do
|
||||||
|
local query
|
||||||
|
query="$(_dns_build_dnsbl_query "$ip" "$bl")"
|
||||||
|
local result
|
||||||
|
result="$(dig +short A "$query" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$result" ]]; then
|
||||||
|
echo -e " ${RED}LISTADO${NC} ${bl} ($result)"
|
||||||
|
found=$((found + 1))
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}limpio${NC} ${bl}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
success "La IP no aparece en ninguna lista negra comprobada"
|
||||||
|
else
|
||||||
|
warning "La IP aparece en ${found} lista(s) negra(s)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
analyze_dns() {
|
||||||
|
local domain="$1"
|
||||||
|
local mode="${2:-all}"
|
||||||
|
|
||||||
|
if [[ -z "$domain" ]]; then
|
||||||
|
error "analyze_dns: se requiere un dominio como primer argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! _dns_is_valid_domain "$domain"; then
|
||||||
|
error "analyze_dns: dominio no válido: '$domain'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dig &>/dev/null; then
|
||||||
|
error "analyze_dns: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Analizando: ${domain}"
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
records)
|
||||||
|
_dns_show_all_records "$domain"
|
||||||
|
;;
|
||||||
|
whois)
|
||||||
|
if ! command -v whois &>/dev/null; then
|
||||||
|
error "analyze_dns: 'whois' no está instalado (sudo apt install whois)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
_dns_show_whois "$domain"
|
||||||
|
;;
|
||||||
|
dnsbl)
|
||||||
|
_dns_check_dnsbl "$domain"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_dns_show_all_records "$domain"
|
||||||
|
if command -v whois &>/dev/null; then
|
||||||
|
_dns_show_whois "$domain"
|
||||||
|
else
|
||||||
|
warning "whois no disponible, omitiendo"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
_dns_check_dnsbl "$domain"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "analyze_dns: modo no válido '$mode'. Use: records|whois|dnsbl|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
analyze_dns "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: audit_http_headers
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "audit_http_headers(url: string) -> void"
|
||||||
|
description: "Audita las cabeceras HTTP de seguridad de una URL: verifica la presencia de HSTS (con validación de max-age mínimo de 6 meses), Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy y cabeceras CORS. También detecta cabeceras que exponen información del servidor."
|
||||||
|
tags: [bash, cybersecurity, web, http, headers, security, hsts, csp, hardening]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: url
|
||||||
|
desc: "URL del sitio web a auditar; si no tiene esquema se añade https:// automáticamente"
|
||||||
|
output: "imprime el estado de cada cabecera de seguridad (ok/falta/advertencia), el valor de las presentes y cabeceras que exponen información del servidor"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/audit_http_headers.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/cabeceras_http.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/audit_http_headers.sh
|
||||||
|
|
||||||
|
# Con URL completa
|
||||||
|
audit_http_headers https://example.com
|
||||||
|
|
||||||
|
# Sin esquema (añade https:// automáticamente)
|
||||||
|
audit_http_headers example.com
|
||||||
|
|
||||||
|
# Seguir redirecciones
|
||||||
|
audit_http_headers http://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `curl -sI --location` para seguir redirecciones y obtener solo cabeceras. El check de HSTS valida que `max-age` sea >= 15.768.000 segundos (6 meses), valor mínimo recomendado por OWASP. Las cabeceras Server, X-Powered-By, X-AspNet-Version y X-Generator se marcan como advertencia por revelar información del stack tecnológico. Timeout de 15 segundos para evitar cuelgues.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit_http_headers
|
||||||
|
# ------------------
|
||||||
|
# Audita las cabeceras HTTP de seguridad de una URL: HSTS, CSP, X-Frame-Options,
|
||||||
|
# X-Content-Type-Options, Referrer-Policy, Permissions-Policy y otras.
|
||||||
|
# También muestra cabeceras que exponen información del servidor.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# audit_http_headers <url>
|
||||||
|
#
|
||||||
|
# Depende de: curl
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hdr_normalize_url() {
|
||||||
|
local url="$1"
|
||||||
|
if [[ ! "$url" =~ ^https?:// ]]; then
|
||||||
|
echo "https://${url}"
|
||||||
|
else
|
||||||
|
echo "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_header_present() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "$headers" | grep -qi "^${name}:"
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_extract_value() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
echo "$headers" | grep -i "^${name}:" | cut -d: -f2- | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_hsts_is_strong() {
|
||||||
|
local value="$1"
|
||||||
|
local max_age
|
||||||
|
max_age="$(echo "$value" | grep -oE 'max-age=[0-9]+' | cut -d= -f2 || echo 0)"
|
||||||
|
[[ "$max_age" -ge 15768000 ]] # 6 meses en segundos
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hdr_fetch() {
|
||||||
|
local url="$1"
|
||||||
|
curl -sI --max-time 15 --location "$url" 2>/dev/null | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_check_header() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="$2"
|
||||||
|
local description="$3"
|
||||||
|
|
||||||
|
if _hdr_header_present "$headers" "$name"; then
|
||||||
|
local value
|
||||||
|
value="$(_hdr_extract_value "$headers" "$name")"
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||||
|
echo -e " ${GRAY}${value}${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x] ${NC} ${name} -- ${description}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_check_hsts() {
|
||||||
|
local headers="$1"
|
||||||
|
local name="Strict-Transport-Security"
|
||||||
|
if _hdr_header_present "$headers" "$name"; then
|
||||||
|
local value
|
||||||
|
value="$(_hdr_extract_value "$headers" "$name")"
|
||||||
|
if _hdr_hsts_is_strong "$value"; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${name}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}[!] ${NC} ${name} -- max-age demasiado corto (<6 meses)"
|
||||||
|
fi
|
||||||
|
echo -e " ${GRAY}${value}${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x] ${NC} ${name} -- HSTS no configurado"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_hdr_show_server_info() {
|
||||||
|
local headers="$1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}── Información del servidor ──────────────────────${NC}"
|
||||||
|
|
||||||
|
for h in Server X-Powered-By X-AspNet-Version X-Generator; do
|
||||||
|
if _hdr_header_present "$headers" "$h"; then
|
||||||
|
local val
|
||||||
|
val="$(_hdr_extract_value "$headers" "$h")"
|
||||||
|
echo -e " ${YELLOW}[!]${NC} ${h}: ${val} (información expuesta)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local status
|
||||||
|
status="$(echo "$headers" | head -1)"
|
||||||
|
echo -e " ${CYAN}Status:${NC} ${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
audit_http_headers() {
|
||||||
|
local raw_url="$1"
|
||||||
|
|
||||||
|
if [[ -z "$raw_url" ]]; then
|
||||||
|
error "audit_http_headers: se requiere una URL como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
error "audit_http_headers: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url
|
||||||
|
url="$(_hdr_normalize_url "$raw_url")"
|
||||||
|
|
||||||
|
info "Consultando cabeceras de: ${url}"
|
||||||
|
local headers
|
||||||
|
headers="$(_hdr_fetch "$url")"
|
||||||
|
|
||||||
|
if [[ -z "$headers" ]]; then
|
||||||
|
error "audit_http_headers: no se pudieron obtener las cabeceras. ¿El sitio está disponible?" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Cabeceras de Seguridad ════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
_hdr_check_hsts "$headers"
|
||||||
|
_hdr_check_header "$headers" "Content-Security-Policy" "Previene XSS e inyección de contenido"
|
||||||
|
_hdr_check_header "$headers" "X-Frame-Options" "Previene clickjacking"
|
||||||
|
_hdr_check_header "$headers" "X-Content-Type-Options" "Previene MIME sniffing"
|
||||||
|
_hdr_check_header "$headers" "Referrer-Policy" "Controla información del referrer"
|
||||||
|
_hdr_check_header "$headers" "Permissions-Policy" "Controla acceso a APIs del navegador"
|
||||||
|
_hdr_check_header "$headers" "Cross-Origin-Opener-Policy" "Aísla el contexto de navegación"
|
||||||
|
_hdr_check_header "$headers" "Cross-Origin-Resource-Policy" "Controla compartición de recursos"
|
||||||
|
|
||||||
|
_hdr_show_server_info "$headers"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
audit_http_headers "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: audit_ssh_config
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "audit_ssh_config(config_path: string) -> void"
|
||||||
|
description: "Audita la configuración de sshd_config evaluando parámetros de seguridad críticos (PermitRootLogin, PasswordAuthentication, Port, MaxAuthTries, X11Forwarding, AllowUsers). También revisa intentos de login fallidos en los logs y lista las claves autorizadas del usuario actual."
|
||||||
|
tags: [bash, cybersecurity, ssh, audit, security, hardening, linux]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: config_path
|
||||||
|
desc: "ruta al archivo sshd_config a auditar (por defecto: /etc/ssh/sshd_config)"
|
||||||
|
output: "imprime checks con nivel ok/warn/bad para cada parámetro, últimos 10 intentos de login fallidos y lista de claves autorizadas"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/audit_ssh_config.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/auditar_ssh.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/audit_ssh_config.sh
|
||||||
|
|
||||||
|
# Auditar la configuración por defecto
|
||||||
|
audit_ssh_config
|
||||||
|
|
||||||
|
# Auditar un archivo alternativo
|
||||||
|
audit_ssh_config /etc/ssh/sshd_config.d/custom.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Los logs de intentos fallidos se buscan primero en `journalctl` (systemd) y si no está disponible en `/var/log/auth.log`. Leer `/etc/ssh/sshd_config` puede requerir permisos de root en algunos sistemas. Los criterios de evaluación siguen las recomendaciones de CIS Benchmark para SSH: PermitRootLogin=no, PasswordAuthentication=no, MaxAuthTries<=3.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit_ssh_config
|
||||||
|
# ----------------
|
||||||
|
# Audita la configuración de sshd_config evaluando parámetros de seguridad,
|
||||||
|
# revisa intentos de login fallidos y lista las claves autorizadas del usuario.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# audit_ssh_config [/ruta/a/sshd_config]
|
||||||
|
#
|
||||||
|
# Depende de: grep, ssh (opcional para validación), journalctl o /var/log/auth.log
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssh_get_value() {
|
||||||
|
local config="$1"
|
||||||
|
local key="$2"
|
||||||
|
grep -iE "^[[:space:]]*${key}[[:space:]]" "$config" 2>/dev/null \
|
||||||
|
| tail -1 | awk '{print $2}' | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_permit_root() {
|
||||||
|
local val="${1:-yes}"
|
||||||
|
case "${val,,}" in
|
||||||
|
no|prohibit-password) echo "ok" ;;
|
||||||
|
without-password) echo "warn" ;;
|
||||||
|
*) echo "bad" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_password_auth() {
|
||||||
|
local val="${1:-yes}"
|
||||||
|
[[ "${val,,}" == "no" ]] && echo "ok" || echo "bad"
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_max_auth_tries() {
|
||||||
|
local val="${1:-6}"
|
||||||
|
[[ "$val" -le 3 ]] && echo "ok" || echo "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_eval_x11_forwarding() {
|
||||||
|
local val="${1:-no}"
|
||||||
|
[[ "${val,,}" == "no" ]] && echo "ok" || echo "warn"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de presentación ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssh_print_check() {
|
||||||
|
local level="$1"
|
||||||
|
local label="$2"
|
||||||
|
local value="$3"
|
||||||
|
local note="$4"
|
||||||
|
|
||||||
|
case "$level" in
|
||||||
|
ok) echo -e " ${GREEN}[ok]${NC} ${label}: ${value}" ;;
|
||||||
|
warn) echo -e " ${YELLOW}[!] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||||
|
bad) echo -e " ${RED}[x] ${NC} ${label}: ${value} -- ${note}" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_config_checks() {
|
||||||
|
local config="$1"
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════ Configuración sshd_config ════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local permit_root
|
||||||
|
permit_root="$(_ssh_get_value "$config" "PermitRootLogin")"
|
||||||
|
permit_root="${permit_root:-yes (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_permit_root "$permit_root")" \
|
||||||
|
"PermitRootLogin" "$permit_root" "debería ser 'no' o 'prohibit-password'"
|
||||||
|
|
||||||
|
local pass_auth
|
||||||
|
pass_auth="$(_ssh_get_value "$config" "PasswordAuthentication")"
|
||||||
|
pass_auth="${pass_auth:-yes (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_password_auth "$pass_auth")" \
|
||||||
|
"PasswordAuthentication" "$pass_auth" "debería ser 'no' (usar claves)"
|
||||||
|
|
||||||
|
local port
|
||||||
|
port="$(_ssh_get_value "$config" "Port")"
|
||||||
|
port="${port:-22 (por defecto)}"
|
||||||
|
if [[ "$port" == "22"* ]]; then
|
||||||
|
_ssh_print_check "warn" "Port" "$port" "considera cambiar el puerto 22"
|
||||||
|
else
|
||||||
|
_ssh_print_check "ok" "Port" "$port" ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
local max_tries
|
||||||
|
max_tries="$(_ssh_get_value "$config" "MaxAuthTries")"
|
||||||
|
max_tries="${max_tries:-6 (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_max_auth_tries "${max_tries%% *}")" \
|
||||||
|
"MaxAuthTries" "$max_tries" "recomendado <= 3"
|
||||||
|
|
||||||
|
local x11
|
||||||
|
x11="$(_ssh_get_value "$config" "X11Forwarding")"
|
||||||
|
x11="${x11:-no (por defecto)}"
|
||||||
|
_ssh_print_check "$(_ssh_eval_x11_forwarding "$x11")" \
|
||||||
|
"X11Forwarding" "$x11" "deshabilitar si no se usa"
|
||||||
|
|
||||||
|
local allow_users allow_groups
|
||||||
|
allow_users="$(_ssh_get_value "$config" "AllowUsers")"
|
||||||
|
allow_groups="$(_ssh_get_value "$config" "AllowGroups")"
|
||||||
|
if [[ -z "$allow_users" && -z "$allow_groups" ]]; then
|
||||||
|
_ssh_print_check "warn" "AllowUsers/AllowGroups" "(no definidos)" "considera restringir acceso por usuario o grupo"
|
||||||
|
else
|
||||||
|
[[ -n "$allow_users" ]] && _ssh_print_check "ok" "AllowUsers" "$allow_users" ""
|
||||||
|
[[ -n "$allow_groups" ]] && _ssh_print_check "ok" "AllowGroups" "$allow_groups" ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_failed_logins() {
|
||||||
|
echo -e "${PURPLE}════════ Últimos intentos de login fallidos ════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v journalctl &>/dev/null; then
|
||||||
|
journalctl -u ssh -u sshd --no-pager -q 2>/dev/null \
|
||||||
|
| grep -i "failed\|invalid\|error" | tail -10 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||||
|
elif [[ -f /var/log/auth.log ]]; then
|
||||||
|
grep -i "failed\|invalid" /var/log/auth.log 2>/dev/null | tail -10 \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done || true
|
||||||
|
else
|
||||||
|
info "No se encontró fuente de logs de autenticación"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssh_show_authorized_keys() {
|
||||||
|
echo -e "${PURPLE}════════ Claves autorizadas (~/.ssh) ═══════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local auth_keys="$HOME/.ssh/authorized_keys"
|
||||||
|
if [[ -f "$auth_keys" ]]; then
|
||||||
|
local count
|
||||||
|
count="$(wc -l < "$auth_keys")"
|
||||||
|
info "${count} clave(s) en authorized_keys:"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" || "$line" == "#"* ]] && continue
|
||||||
|
local key_type key_comment
|
||||||
|
key_type="$(echo "$line" | awk '{print $1}')"
|
||||||
|
key_comment="$(echo "$line" | awk '{print $NF}')"
|
||||||
|
echo -e " ${GREEN}*${NC} ${key_type} -- ${key_comment}"
|
||||||
|
done < "$auth_keys"
|
||||||
|
else
|
||||||
|
info "No existe $auth_keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
audit_ssh_config() {
|
||||||
|
local config_path="${1:-/etc/ssh/sshd_config}"
|
||||||
|
|
||||||
|
if [[ ! -f "$config_path" ]]; then
|
||||||
|
warning "audit_ssh_config: no se encontró $config_path -- ¿está instalado sshd?"
|
||||||
|
else
|
||||||
|
_ssh_show_config_checks "$config_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
_ssh_show_failed_logins
|
||||||
|
_ssh_show_authorized_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
audit_ssh_config "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: check_firewall
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "check_firewall() -> void"
|
||||||
|
description: "Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra su estado, reglas activas y puertos en escucha para cruzar con las reglas. Si no se detecta ningún firewall, emite una advertencia de exposición."
|
||||||
|
tags: [bash, cybersecurity, firewall, ufw, iptables, network, hardening, linux]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "imprime el firewall detectado, su estado (activo/inactivo), reglas vigentes y lista de puertos en escucha"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/check_firewall.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/firewall_status.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/check_firewall.sh
|
||||||
|
|
||||||
|
check_firewall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
La detección sigue el orden: ufw > firewalld > iptables > none. Para ufw muestra `ufw status verbose`; para firewalld muestra zona por defecto, servicios y puertos permitidos; para iptables muestra las cadenas INPUT/OUTPUT/FORWARD. Leer reglas de iptables requiere privilegios de root. El cruce de puertos en escucha (via `ss -tlnp`) ayuda a identificar servicios sin regla de firewall correspondiente.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check_firewall
|
||||||
|
# --------------
|
||||||
|
# Detecta el firewall activo del sistema (ufw, firewalld o iptables) y muestra
|
||||||
|
# su estado y reglas. También lista los puertos en escucha para cruzar con reglas.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# check_firewall
|
||||||
|
#
|
||||||
|
# Depende de: ufw, firewall-cmd o iptables (el que esté disponible), ss
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_fw_detect() {
|
||||||
|
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status:"; then
|
||||||
|
echo "ufw"
|
||||||
|
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q "running"; then
|
||||||
|
echo "firewalld"
|
||||||
|
elif command -v iptables &>/dev/null; then
|
||||||
|
echo "iptables"
|
||||||
|
else
|
||||||
|
echo "none"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_ufw_is_active() {
|
||||||
|
ufw status 2>/dev/null | grep -q "Status: active"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_firewalld_is_running() {
|
||||||
|
firewall-cmd --state 2>/dev/null | grep -q "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_iptables_has_rules() {
|
||||||
|
local count
|
||||||
|
count="$(iptables -L INPUT --line-numbers 2>/dev/null | grep -c "^[0-9]" || echo 0)"
|
||||||
|
[[ "$count" -gt 0 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_fw_show_ufw() {
|
||||||
|
echo -e "${PURPLE}════════ UFW ════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if _fw_ufw_is_active; then
|
||||||
|
success "UFW está activo"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} UFW está INACTIVO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Reglas activas:"
|
||||||
|
ufw status verbose 2>/dev/null | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_firewalld() {
|
||||||
|
echo -e "${PURPLE}════════ FirewallD ══════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if _fw_firewalld_is_running; then
|
||||||
|
success "firewalld está activo"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} firewalld está INACTIVO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local zone
|
||||||
|
zone="$(firewall-cmd --get-default-zone 2>/dev/null || echo "desconocida")"
|
||||||
|
info "Zona por defecto: ${zone}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
info "Servicios permitidos en zona ${zone}:"
|
||||||
|
firewall-cmd --zone="$zone" --list-services 2>/dev/null \
|
||||||
|
| tr ' ' '\n' | while IFS= read -r svc; do
|
||||||
|
echo -e " ${GREEN}*${NC} ${svc}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Puertos permitidos:"
|
||||||
|
firewall-cmd --zone="$zone" --list-ports 2>/dev/null \
|
||||||
|
| tr ' ' '\n' | while IFS= read -r port; do
|
||||||
|
[[ -n "$port" ]] && echo -e " ${YELLOW}*${NC} ${port}"
|
||||||
|
done || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_iptables() {
|
||||||
|
echo -e "${PURPLE}════════ iptables ═══════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for chain in INPUT OUTPUT FORWARD; do
|
||||||
|
echo -e "${CYAN}── ${chain} ──${NC}"
|
||||||
|
iptables -L "$chain" --line-numbers -n 2>/dev/null \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! _fw_iptables_has_rules; then
|
||||||
|
echo -e " ${YELLOW}[!]${NC} No hay reglas INPUT definidas -- el sistema puede estar sin filtrar tráfico"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_none() {
|
||||||
|
echo ""
|
||||||
|
echo -e " ${RED}[x]${NC} No se detectó ningún firewall activo (ufw, firewalld, iptables)"
|
||||||
|
echo -e " ${YELLOW}[!]${NC} El sistema puede estar completamente expuesto"
|
||||||
|
echo ""
|
||||||
|
info "Para instalar y activar ufw: sudo apt install ufw && sudo ufw enable"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fw_show_listening_crosscheck() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Puertos en escucha (para cruzar con reglas) ════${NC}"
|
||||||
|
echo ""
|
||||||
|
ss -tlnp 2>/dev/null | tail -n +2 | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_firewall() {
|
||||||
|
local fw
|
||||||
|
fw="$(_fw_detect)"
|
||||||
|
|
||||||
|
info "Firewall detectado: ${fw}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$fw" in
|
||||||
|
ufw) _fw_show_ufw ;;
|
||||||
|
firewalld) _fw_show_firewalld ;;
|
||||||
|
iptables) _fw_show_iptables ;;
|
||||||
|
none) _fw_show_none ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
_fw_show_listening_crosscheck
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
check_firewall "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: detect_suspicious_users
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "detect_suspicious_users() -> void"
|
||||||
|
description: "Revisa el sistema en busca de indicadores de compromiso en cuentas de usuario: UIDs 0 extras (además de root), usuarios con shell de login válida, homes en rutas inusuales, miembros de grupos privilegiados (sudo, docker, wheel, adm, etc.) y sesiones activas."
|
||||||
|
tags: [bash, cybersecurity, users, audit, linux, privilege-escalation, hardening]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "imprime secciones con UIDs 0, usuarios con shell, homes inusuales, grupos privilegiados, últimos logins y sesiones activas"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/detect_suspicious_users.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/sistema/usuarios_sospechosos.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/detect_suspicious_users.sh
|
||||||
|
|
||||||
|
detect_suspicious_users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Los usuarios de sistema (UID <= 999) se excluyen del check de shell válida para evitar falsos positivos. Los grupos privilegiados monitorizados son: sudo, wheel, docker, adm, lxd, libvirt, kvm, disk, shadow. Homes inusuales son aquellos fuera de /home, /root, /var, /srv, /nonexistent y /tmp. `lastlog` puede no estar disponible en todas las distribuciones.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect_suspicious_users
|
||||||
|
# -----------------------
|
||||||
|
# Revisa el sistema en busca de usuarios potencialmente sospechosos:
|
||||||
|
# UIDs 0 extras, shells válidas, homes en rutas inusuales, grupos privilegiados
|
||||||
|
# y sesiones activas.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# detect_suspicious_users
|
||||||
|
#
|
||||||
|
# Depende de: /etc/passwd, getent, w, lastlog
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUSPICIOUS_VALID_SHELLS=("/bin/bash" "/bin/sh" "/bin/zsh" "/bin/fish" "/usr/bin/bash" "/usr/bin/zsh" "/usr/bin/fish")
|
||||||
|
_SUSPICIOUS_PRIVILEGED_GROUPS=("sudo" "wheel" "docker" "adm" "lxd" "libvirt" "kvm" "disk" "shadow")
|
||||||
|
_SUSPICIOUS_SYSTEM_USERS_MAX_UID=999
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sus_is_valid_shell() {
|
||||||
|
local shell="$1"
|
||||||
|
for s in "${_SUSPICIOUS_VALID_SHELLS[@]}"; do
|
||||||
|
[[ "$shell" == "$s" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_is_system_user() {
|
||||||
|
local uid="$1"
|
||||||
|
[[ "$uid" -le $_SUSPICIOUS_SYSTEM_USERS_MAX_UID ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_is_unusual_home() {
|
||||||
|
local home="$1"
|
||||||
|
[[ ! "$home" =~ ^(/home|/root|/var|/srv|/nonexistent|/tmp) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sus_show_uid0_users() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con UID 0 (root) ═════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _; do
|
||||||
|
if [[ "$uid" -eq 0 ]]; then
|
||||||
|
if [[ "$username" == "root" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} root (esperado)"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[x]${NC} ${username} tiene UID 0 -- SOSPECHOSO"
|
||||||
|
fi
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
if [[ $found -eq 1 ]]; then
|
||||||
|
echo ""
|
||||||
|
success "Solo root tiene UID 0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_users_with_shell() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con shell de login válida ═════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GRAY}(excluye usuarios de sistema con UID <= ${_SUSPICIOUS_SYSTEM_USERS_MAX_UID})${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _ _ home shell; do
|
||||||
|
if ! _sus_is_system_user "$uid" && _sus_is_valid_shell "$shell"; then
|
||||||
|
echo -e " ${CYAN}*${NC} ${username} (UID ${uid}) -- shell: ${shell} -- home: ${home}"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
[[ $found -eq 0 ]] && info "No se encontraron usuarios normales con shell válida"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_unusual_homes() {
|
||||||
|
echo -e "${PURPLE}════════ Usuarios con home inusual ══════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS=: read -r username _ uid _ _ home shell; do
|
||||||
|
if _sus_is_valid_shell "$shell" && _sus_is_unusual_home "$home"; then
|
||||||
|
echo -e " ${YELLOW}[!]${NC} ${username} -- home: ${home}"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < /etc/passwd
|
||||||
|
|
||||||
|
[[ $found -eq 0 ]] && success "No se detectaron homes en rutas inusuales"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_privileged_groups() {
|
||||||
|
echo -e "${PURPLE}════════ Grupos privilegiados y sus miembros ════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for group in "${_SUSPICIOUS_PRIVILEGED_GROUPS[@]}"; do
|
||||||
|
local members
|
||||||
|
members="$(getent group "$group" 2>/dev/null | cut -d: -f4 || true)"
|
||||||
|
if [[ -n "$members" ]]; then
|
||||||
|
echo -e " ${YELLOW}*${NC} ${group}: ${members}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_active_sessions() {
|
||||||
|
echo -e "${PURPLE}════════ Sesiones activas ═══════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
w 2>/dev/null | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_sus_show_last_logins() {
|
||||||
|
echo -e "${PURPLE}════════ Últimos logins por usuario ═════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v lastlog &>/dev/null; then
|
||||||
|
lastlog 2>/dev/null | awk 'NR==1 || $NF != "logged" {
|
||||||
|
if (NR==1 || $2 != "**Never") printf " %-16s %-10s %s\n", $1, $2, $NF
|
||||||
|
}' | grep -v "^$" | while IFS= read -r line; do
|
||||||
|
echo -e " ${DIM_GRAY}${line}${NC}"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
warning "lastlog no disponible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
detect_suspicious_users() {
|
||||||
|
_sus_show_uid0_users
|
||||||
|
_sus_show_users_with_shell
|
||||||
|
_sus_show_unusual_homes
|
||||||
|
_sus_show_privileged_groups
|
||||||
|
_sus_show_last_logins
|
||||||
|
_sus_show_active_sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
detect_suspicious_users "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: encrypt_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "encrypt_file(mode: string, file: string) -> void"
|
||||||
|
description: "Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones) via openssl. La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita interactivamente. El archivo cifrado se guarda con extensión .enc; al descifrar se recupera el nombre original."
|
||||||
|
tags: [bash, cybersecurity, encryption, aes256, openssl, crypto, pbkdf2]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "operación a realizar: encrypt (cifrar) o decrypt (descifrar)"
|
||||||
|
- name: file
|
||||||
|
desc: "ruta al archivo a cifrar o descifrar"
|
||||||
|
output: "genera el archivo cifrado (input.enc) o descifrado (input sin .enc, o input.dec) e imprime progreso a stdout"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/encrypt_file.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/cifrar_archivo.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/encrypt_file.sh
|
||||||
|
|
||||||
|
# Cifrar (solicita contraseña interactivamente)
|
||||||
|
encrypt_file encrypt documento.pdf
|
||||||
|
|
||||||
|
# Descifrar
|
||||||
|
encrypt_file decrypt documento.pdf.enc
|
||||||
|
|
||||||
|
# Con contraseña via variable de entorno (no interactivo)
|
||||||
|
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file encrypt datos.tar.gz
|
||||||
|
ENCRYPT_PASSWORD="mi-secreto-seguro" encrypt_file decrypt datos.tar.gz.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `openssl enc -aes-256-cbc -pbkdf2 -iter 310000` — compatible con OpenSSL 1.1.1+. Las 310.000 iteraciones de PBKDF2 siguen las recomendaciones NIST para derivación de claves en 2024. La contraseña se limpia de memoria al terminar. Si el archivo de salida ya existe, la función falla silenciosamente (no sobrescribe por seguridad cuando se usa con ENCRYPT_PASSWORD).
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# encrypt_file
|
||||||
|
# ------------
|
||||||
|
# Cifra o descifra un archivo usando AES-256-CBC con PBKDF2 (310.000 iteraciones).
|
||||||
|
# La contraseña se lee de la variable de entorno ENCRYPT_PASSWORD o se solicita
|
||||||
|
# interactivamente por stdin.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# encrypt_file encrypt <archivo>
|
||||||
|
# encrypt_file decrypt <archivo.enc>
|
||||||
|
# ENCRYPT_PASSWORD=secreto encrypt_file encrypt archivo.txt
|
||||||
|
#
|
||||||
|
# Depende de: openssl
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_enc_output_path_encrypt() {
|
||||||
|
local input="$1"
|
||||||
|
echo "${input}.enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_output_path_decrypt() {
|
||||||
|
local input="$1"
|
||||||
|
if [[ "$input" == *.enc ]]; then
|
||||||
|
echo "${input%.enc}"
|
||||||
|
else
|
||||||
|
echo "${input}.dec"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_human_size() {
|
||||||
|
local file="$1"
|
||||||
|
du -sh "$file" 2>/dev/null | cut -f1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────name──────────
|
||||||
|
|
||||||
|
_enc_ask_password() {
|
||||||
|
local pass
|
||||||
|
read -rsp "Contraseña: " pass
|
||||||
|
echo "" >&2
|
||||||
|
echo "$pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_ask_password_confirm() {
|
||||||
|
local pass1 pass2
|
||||||
|
read -rsp "Contraseña: " pass1
|
||||||
|
echo "" >&2
|
||||||
|
read -rsp "Confirmar contraseña: " pass2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
if [[ "$pass1" != "$pass2" ]]; then
|
||||||
|
error "Las contraseñas no coinciden" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#pass1} -lt 8 ]]; then
|
||||||
|
warning "La contraseña es muy corta (mínimo 8 caracteres recomendado)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$pass1"
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_do_encrypt() {
|
||||||
|
local input="$1"
|
||||||
|
local output="$2"
|
||||||
|
local password="$3"
|
||||||
|
|
||||||
|
openssl enc -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||||
|
-in "$input" -out "$output" \
|
||||||
|
-pass "pass:${password}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_enc_do_decrypt() {
|
||||||
|
local input="$1"
|
||||||
|
local output="$2"
|
||||||
|
local password="$3"
|
||||||
|
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -iter 310000 \
|
||||||
|
-in "$input" -out "$output" \
|
||||||
|
-pass "pass:${password}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
encrypt_file() {
|
||||||
|
local mode="$1"
|
||||||
|
local file="$2"
|
||||||
|
|
||||||
|
if [[ -z "$mode" || -z "$file" ]]; then
|
||||||
|
error "encrypt_file: uso: encrypt_file <encrypt|decrypt> <archivo>" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "encrypt_file: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
error "encrypt_file: archivo no encontrado: $file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local password
|
||||||
|
if [[ -n "${ENCRYPT_PASSWORD:-}" ]]; then
|
||||||
|
password="$ENCRYPT_PASSWORD"
|
||||||
|
else
|
||||||
|
case "$mode" in
|
||||||
|
encrypt) password="$(_enc_ask_password_confirm)" || return 1 ;;
|
||||||
|
decrypt) password="$(_enc_ask_password)" ;;
|
||||||
|
*)
|
||||||
|
error "encrypt_file: modo no válido '$mode'. Use: encrypt|decrypt" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
encrypt)
|
||||||
|
local output
|
||||||
|
output="$(_enc_output_path_encrypt "$file")"
|
||||||
|
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||||
|
info "Salida: ${output}"
|
||||||
|
info "Cifrando con AES-256-CBC + PBKDF2..."
|
||||||
|
|
||||||
|
if _enc_do_encrypt "$file" "$output" "$password"; then
|
||||||
|
success "Archivo cifrado: ${output} ($(_enc_human_size "$output"))"
|
||||||
|
else
|
||||||
|
error "encrypt_file: el cifrado falló" >&2
|
||||||
|
rm -f "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
decrypt)
|
||||||
|
local output
|
||||||
|
output="$(_enc_output_path_decrypt "$file")"
|
||||||
|
info "Archivo: ${file} ($(_enc_human_size "$file"))"
|
||||||
|
info "Salida: ${output}"
|
||||||
|
info "Descifrando..."
|
||||||
|
|
||||||
|
if _enc_do_decrypt "$file" "$output" "$password"; then
|
||||||
|
success "Archivo descifrado: ${output} ($(_enc_human_size "$output"))"
|
||||||
|
else
|
||||||
|
error "encrypt_file: el descifrado falló -- ¿contraseña incorrecta?" >&2
|
||||||
|
rm -f "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Limpiar contraseña de memoria
|
||||||
|
password=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
encrypt_file "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: enumerate_subdomains
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "enumerate_subdomains(domain: string, output_file: string) -> void"
|
||||||
|
description: "Enumera subdominios de un dominio objetivo usando un diccionario integrado de ~100 subdominios comunes (www, mail, api, dev, admin, vpn, etc.). Detecta tanto registros A (IP directa) como CNAME. Muestra progreso cada 20 subdominios y opcionalmente guarda los resultados en un archivo."
|
||||||
|
tags: [bash, cybersecurity, dns, subdomain, enumeration, reconnaissance, osint]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: domain
|
||||||
|
desc: "dominio objetivo a enumerar, ej: example.com"
|
||||||
|
- name: output_file
|
||||||
|
desc: "ruta al archivo donde guardar los resultados (opcional; si se omite, solo imprime a stdout)"
|
||||||
|
output: "imprime subdominios encontrados con su IP o CNAME, progreso cada 20 entradas y resumen final con total encontrados"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/enumerate_subdomains.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/subdominios.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/enumerate_subdomains.sh
|
||||||
|
|
||||||
|
# Solo imprimir resultados
|
||||||
|
enumerate_subdomains example.com
|
||||||
|
|
||||||
|
# Guardar resultados en archivo
|
||||||
|
enumerate_subdomains example.com /tmp/subdominios.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `dig` (paquete dnsutils). El diccionario integrado cubre subdominios comunes en entornos corporativos y de desarrollo: www, api, dev, admin, vpn, git, jenkins, staging, prod, db, mail, smtp, ns1/ns2, grafana, kibana, docker, k8s, auth, sso, etc. (~100 entradas). La enumeración es puramente pasiva via DNS — no realiza ningún tipo de conexión al servidor web. Los subdominios con CNAME sin resolución A se marcan en amarillo.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# enumerate_subdomains
|
||||||
|
# --------------------
|
||||||
|
# Enumera subdominios de un dominio objetivo usando un diccionario integrado de
|
||||||
|
# ~100 subdominios comunes. Detecta tanto registros A (IP directa) como CNAME.
|
||||||
|
# Opcionalmente guarda el resultado en un archivo.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# enumerate_subdomains <dominio> [archivo_salida]
|
||||||
|
# enumerate_subdomains example.com
|
||||||
|
# enumerate_subdomains example.com /tmp/resultado.txt
|
||||||
|
#
|
||||||
|
# Depende de: dig (dnsutils)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Diccionario integrado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUBDOMAIN_WORDLIST=(
|
||||||
|
www mail ftp api dev admin vpn ssh git gitlab github jenkins ci cd
|
||||||
|
staging prod test demo beta alpha app web portal intranet extranet
|
||||||
|
remote desktop files cdn static assets media img images upload
|
||||||
|
db database mysql postgres redis mongo smtp pop imap webmail mx
|
||||||
|
ns1 ns2 dns autodiscover autoconfig crm erp shop store payment
|
||||||
|
backup old legacy v1 v2 v3 internal corp office support helpdesk
|
||||||
|
wiki docs doc status monitor grafana kibana elastic search
|
||||||
|
registry docker k8s kubernetes auth sso login oauth api2 mobile
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sub_is_valid_domain() {
|
||||||
|
[[ -n "$1" && "$1" =~ ^[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_sub_build_fqdn() {
|
||||||
|
local sub="$1"
|
||||||
|
local domain="$2"
|
||||||
|
echo "${sub}.${domain}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sub_resolve_a() {
|
||||||
|
local fqdn="$1"
|
||||||
|
dig +short A "$fqdn" 2>/dev/null | grep -E '^[0-9]+\.' | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_sub_resolve_cname() {
|
||||||
|
local fqdn="$1"
|
||||||
|
dig +short CNAME "$fqdn" 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enumerate_subdomains() {
|
||||||
|
local domain="$1"
|
||||||
|
local output_file="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$domain" ]]; then
|
||||||
|
error "enumerate_subdomains: se requiere un dominio como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! _sub_is_valid_domain "$domain"; then
|
||||||
|
error "enumerate_subdomains: dominio no válido: '$domain'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dig &>/dev/null; then
|
||||||
|
error "enumerate_subdomains: 'dig' no está instalado (sudo apt install dnsutils)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local total="${#_SUBDOMAIN_WORDLIST[@]}"
|
||||||
|
local found=0
|
||||||
|
local checked=0
|
||||||
|
|
||||||
|
info "Probando ${total} subdominios en ${domain}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$output_file" ]]; then
|
||||||
|
echo "# Subdominios encontrados en ${domain} -- $(date)" > "$output_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for sub in "${_SUBDOMAIN_WORDLIST[@]}"; do
|
||||||
|
local fqdn
|
||||||
|
fqdn="$(_sub_build_fqdn "$sub" "$domain")"
|
||||||
|
checked=$((checked + 1))
|
||||||
|
|
||||||
|
local ip
|
||||||
|
ip="$(_sub_resolve_a "$fqdn")"
|
||||||
|
|
||||||
|
if [[ -n "$ip" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${fqdn} -> ${CYAN}${ip}${NC}"
|
||||||
|
[[ -n "$output_file" ]] && echo "${fqdn} -> ${ip}" >> "$output_file"
|
||||||
|
found=$((found + 1))
|
||||||
|
else
|
||||||
|
local cname
|
||||||
|
cname="$(_sub_resolve_cname "$fqdn")"
|
||||||
|
if [[ -n "$cname" ]]; then
|
||||||
|
echo -e " ${YELLOW}[cn]${NC} ${fqdn} -> CNAME: ${cname}"
|
||||||
|
[[ -n "$output_file" ]] && echo "${fqdn} -> CNAME: ${cname}" >> "$output_file"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Progreso cada 20 subdominios
|
||||||
|
if (( checked % 20 == 0 )); then
|
||||||
|
echo -e " ${DIM_GRAY}[${checked}/${total} probados, ${found} encontrados]${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
info "No se encontraron subdominios en el diccionario"
|
||||||
|
else
|
||||||
|
success "Total encontrados: ${found} de ${total} probados"
|
||||||
|
[[ -n "$output_file" ]] && info "Resultado guardado en: ${output_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
enumerate_subdomains "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: generate_password
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "generate_password(mode: string, length: int, count: int) -> void"
|
||||||
|
description: "Genera contraseñas seguras en cuatro modos: full (alfanumérico + símbolos, excluye caracteres ambiguos), alpha (solo alfanumérico), passphrase (palabras aleatorias unidas con guión) y pin (numérico). Calcula y muestra la entropía en bits para cada modo."
|
||||||
|
tags: [bash, cybersecurity, password, generator, entropy, security, urandom]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de generación: full (alfanumérico+símbolos, por defecto), alpha (solo letras y números), passphrase (palabras), pin (numérico)"
|
||||||
|
- name: length
|
||||||
|
desc: "longitud en caracteres para full/alpha/pin, o número de palabras para passphrase (por defecto: 16)"
|
||||||
|
- name: count
|
||||||
|
desc: "número de contraseñas a generar (por defecto: 1)"
|
||||||
|
output: "imprime las contraseñas generadas a stdout (una por línea) con información de entropía"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/generate_password.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/generar_password.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/generate_password.sh
|
||||||
|
|
||||||
|
# Contraseña completa de 20 caracteres
|
||||||
|
generate_password full 20
|
||||||
|
|
||||||
|
# 5 contraseñas alfanuméricas de 16 caracteres
|
||||||
|
generate_password alpha 16 5
|
||||||
|
|
||||||
|
# Passphrase de 6 palabras
|
||||||
|
generate_password passphrase 6
|
||||||
|
|
||||||
|
# PIN de 8 dígitos
|
||||||
|
generate_password pin 8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `/dev/urandom` como fuente de aleatoriedad criptográficamente segura. El modo `full` excluye caracteres ambiguos (0, O, l, I, 1) para mejorar legibilidad. El modo `passphrase` requiere un diccionario del sistema (`/usr/share/dict/words` o similar). La entropía se calcula como log2(charset^length) en bits. Las contraseñas nunca se escriben a disco.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# generate_password
|
||||||
|
# -----------------
|
||||||
|
# Genera contraseñas seguras en varios modos: completo (alfanumérico + símbolos),
|
||||||
|
# solo alfanumérico, passphrase de palabras o PIN numérico.
|
||||||
|
# Calcula la entropía en bits para cada contraseña generada.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# generate_password [full|alpha|passphrase|pin] [longitud] [cantidad]
|
||||||
|
#
|
||||||
|
# Depende de: /dev/urandom, python3 (para entropía), shuf (para passphrases)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GENPW_DEFAULT_LENGTH=16
|
||||||
|
_GENPW_DEFAULT_COUNT=1
|
||||||
|
_GENPW_WORDLIST_PATHS=("/usr/share/dict/words" "/usr/dict/words" "/usr/share/dict/american-english")
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_genpw_find_wordlist() {
|
||||||
|
for path in "${_GENPW_WORDLIST_PATHS[@]}"; do
|
||||||
|
[[ -f "$path" ]] && echo "$path" && return
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_calc_entropy() {
|
||||||
|
local charset_size="$1"
|
||||||
|
local length="$2"
|
||||||
|
python3 -c "import math; print(f'{math.log2(${charset_size}**${length}):.1f}')" 2>/dev/null || echo "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de generación ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_genpw_gen_full() {
|
||||||
|
local length="$1"
|
||||||
|
# Alfanumérico + símbolos (excluye ambiguos: 0OlI1)
|
||||||
|
tr -dc 'A-HJ-NP-Za-km-z2-9!@#$%^&*()_+-=[]{}|;:,.<>?' \
|
||||||
|
< /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_alpha() {
|
||||||
|
local length="$1"
|
||||||
|
tr -dc 'A-HJ-NP-Za-km-z2-9' \
|
||||||
|
< /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_passphrase() {
|
||||||
|
local words="$1"
|
||||||
|
local wordlist
|
||||||
|
wordlist="$(_genpw_find_wordlist)"
|
||||||
|
|
||||||
|
if [[ -z "$wordlist" ]]; then
|
||||||
|
error "generate_password: no se encontró diccionario (sudo apt install wamerican)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local phrase=""
|
||||||
|
for ((i=0; i<words; i++)); do
|
||||||
|
local word
|
||||||
|
word="$(shuf -n1 "$wordlist" | tr -dc 'a-z' | head -c 20)"
|
||||||
|
[[ ${#word} -lt 3 ]] && { i=$((i-1)); continue; }
|
||||||
|
phrase="${phrase}${word}-"
|
||||||
|
done
|
||||||
|
echo "${phrase%-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_genpw_gen_pin() {
|
||||||
|
local length="$1"
|
||||||
|
tr -dc '0-9' < /dev/urandom | head -c "$length"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local mode="${1:-full}"
|
||||||
|
local length="${2:-$_GENPW_DEFAULT_LENGTH}"
|
||||||
|
local count="${3:-$_GENPW_DEFAULT_COUNT}"
|
||||||
|
|
||||||
|
# Validar que length y count son numéricos
|
||||||
|
if ! [[ "$length" =~ ^[0-9]+$ ]] || ! [[ "$count" =~ ^[0-9]+$ ]]; then
|
||||||
|
error "generate_password: longitud y cantidad deben ser números enteros positivos" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local charset_size entropy
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
full)
|
||||||
|
charset_size=78
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "Contraseñas alfanuméricas + símbolos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_full "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
alpha)
|
||||||
|
charset_size=56
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "Contraseñas alfanuméricas (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_alpha "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
passphrase)
|
||||||
|
info "Passphrases (${length} palabras)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_passphrase "$length" || return 1
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
pin)
|
||||||
|
charset_size=10
|
||||||
|
entropy="$(_genpw_calc_entropy $charset_size "$length")"
|
||||||
|
info "PINs numéricos (longitud: ${length}, entropía: ~${entropy} bits)"
|
||||||
|
echo ""
|
||||||
|
for ((i=1; i<=count; i++)); do
|
||||||
|
_genpw_gen_pin "$length"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "generate_password: modo no válido '$mode'. Use: full|alpha|passphrase|pin" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
generate_password "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: geolocate_ip
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "geolocate_ip(target: string) -> void"
|
||||||
|
description: "Geolocaliza una dirección IP o dominio usando la API pública de ip-api.com. Muestra país, región, ciudad, coordenadas, ISP, ASN y detecta VPN, Proxy o infraestructura de hosting."
|
||||||
|
tags: [bash, cybersecurity, network, geoip, ip, osint, reconnaissance]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "dirección IP (IPv4) o nombre de dominio a geolocalizar"
|
||||||
|
output: "imprime información de geolocalización a stdout: país, ciudad, ISP, ASN, coordenadas y flags de VPN/Proxy/Hosting"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/geolocate_ip.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/geoip.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/geolocate_ip.sh
|
||||||
|
|
||||||
|
# Geolocalizar una IP
|
||||||
|
geolocate_ip 8.8.8.8
|
||||||
|
|
||||||
|
# Geolocalizar un dominio (resuelve a IP primero)
|
||||||
|
geolocate_ip example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl`. Si se pasa un dominio en lugar de una IP, se resuelve a IP usando `dig` antes de consultar la API. La API de ip-api.com es gratuita para uso no comercial con límite de 45 req/min. Los campos `proxy=true` y `hosting=true` indican posible uso de VPN, proxy Tor o datacenter.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# geolocate_ip
|
||||||
|
# ------------
|
||||||
|
# Geolocaliza una IP o dominio usando la API pública de ip-api.com.
|
||||||
|
# Muestra país, ciudad, ISP, ASN y detecta VPN/Proxy/Hosting.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# geolocate_ip <ip_o_dominio>
|
||||||
|
#
|
||||||
|
# Depende de: curl, dig (para resolver dominios)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GEOIP_API="http://ip-api.com/json"
|
||||||
|
_GEOIP_FIELDS="status,message,country,countryCode,regionName,city,zip,lat,lon,isp,org,as,proxy,hosting,query"
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_geo_is_ip() {
|
||||||
|
[[ "$1" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_build_api_url() {
|
||||||
|
local target="$1"
|
||||||
|
echo "${_GEOIP_API}/${target}?fields=${_GEOIP_FIELDS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_extract_field() {
|
||||||
|
local json="$1"
|
||||||
|
local key="$2"
|
||||||
|
echo "$json" | grep -o "\"${key}\":[^,}]*" | cut -d: -f2- | tr -d '"' | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_geo_resolve_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
dig +short A "$domain" 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_fetch() {
|
||||||
|
local target="$1"
|
||||||
|
local url
|
||||||
|
url="$(_geo_build_api_url "$target")"
|
||||||
|
curl -s --max-time 10 "$url" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_geo_display_result() {
|
||||||
|
local json="$1"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status="$(_geo_extract_field "$json" "status")"
|
||||||
|
if [[ "$status" != "success" ]]; then
|
||||||
|
local msg
|
||||||
|
msg="$(_geo_extract_field "$json" "message")"
|
||||||
|
error "La API devolvió error: ${msg:-respuesta inesperada}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ip_queried country country_code region city zip lat lon isp org asn proxy hosting
|
||||||
|
ip_queried="$(_geo_extract_field "$json" "query")"
|
||||||
|
country="$(_geo_extract_field "$json" "country")"
|
||||||
|
country_code="$(_geo_extract_field "$json" "countryCode")"
|
||||||
|
region="$(_geo_extract_field "$json" "regionName")"
|
||||||
|
city="$(_geo_extract_field "$json" "city")"
|
||||||
|
zip="$(_geo_extract_field "$json" "zip")"
|
||||||
|
lat="$(_geo_extract_field "$json" "lat")"
|
||||||
|
lon="$(_geo_extract_field "$json" "lon")"
|
||||||
|
isp="$(_geo_extract_field "$json" "isp")"
|
||||||
|
org="$(_geo_extract_field "$json" "org")"
|
||||||
|
asn="$(_geo_extract_field "$json" "as")"
|
||||||
|
proxy="$(_geo_extract_field "$json" "proxy")"
|
||||||
|
hosting="$(_geo_extract_field "$json" "hosting")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e " ${CYAN}IP consultada:${NC} ${ip_queried}"
|
||||||
|
echo -e " ${CYAN}País:${NC} ${country} (${country_code})"
|
||||||
|
echo -e " ${CYAN}Región:${NC} ${region}"
|
||||||
|
echo -e " ${CYAN}Ciudad:${NC} ${city} ${zip}"
|
||||||
|
echo -e " ${CYAN}Coordenadas:${NC} ${lat}, ${lon}"
|
||||||
|
echo -e " ${CYAN}ISP:${NC} ${isp}"
|
||||||
|
echo -e " ${CYAN}Organización:${NC} ${org}"
|
||||||
|
echo -e " ${CYAN}ASN:${NC} ${asn}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$proxy" == "true" ]]; then
|
||||||
|
echo -e " ${RED}[!] VPN / Proxy detectado${NC}"
|
||||||
|
fi
|
||||||
|
if [[ "$hosting" == "true" ]]; then
|
||||||
|
echo -e " ${YELLOW}[i] Hosting / datacenter detectado${NC}"
|
||||||
|
fi
|
||||||
|
if [[ "$proxy" != "true" && "$hosting" != "true" ]]; then
|
||||||
|
echo -e " ${GREEN}[ok] Sin indicios de VPN, Proxy o Tor${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
geolocate_ip() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
error "geolocate_ip: se requiere una IP o dominio como argumento" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
error "geolocate_ip: 'curl' no está instalado (sudo apt install curl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local query_target="$target"
|
||||||
|
|
||||||
|
if ! _geo_is_ip "$target"; then
|
||||||
|
info "Resolviendo dominio a IP..."
|
||||||
|
local resolved
|
||||||
|
resolved="$(_geo_resolve_domain "$target")"
|
||||||
|
if [[ -z "$resolved" ]]; then
|
||||||
|
error "geolocate_ip: no se pudo resolver '$target'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
info "Resuelto: ${target} -> ${resolved}"
|
||||||
|
query_target="$resolved"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Consultando geolocalización de ${query_target}..."
|
||||||
|
local json
|
||||||
|
json="$(_geo_fetch "$query_target")"
|
||||||
|
|
||||||
|
if [[ -z "$json" ]]; then
|
||||||
|
error "geolocate_ip: no se obtuvo respuesta de la API" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_geo_display_result "$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
geolocate_ip "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: inspect_ssl_cert
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "inspect_ssl_cert(host: string) -> void"
|
||||||
|
description: "Inspecciona el certificado SSL/TLS de un host: muestra sujeto, emisor, fechas de validez, días hasta expiración, SANs (Subject Alternative Names), cadena de confianza completa y detecta soporte de versiones inseguras TLS 1.0/1.1."
|
||||||
|
tags: [bash, cybersecurity, ssl, tls, certificate, web, openssl, security]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "host a inspeccionar, acepta formato host o host:puerto (por defecto puerto 443), ej: example.com o example.com:8443"
|
||||||
|
output: "imprime detalles del certificado SSL/TLS, días hasta expiración con nivel de alerta, SANs, cadena de confianza y resultado de checks de versiones TLS"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/inspect_ssl_cert.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/web/ssl_cert_info.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/inspect_ssl_cert.sh
|
||||||
|
|
||||||
|
# Puerto 443 por defecto
|
||||||
|
inspect_ssl_cert example.com
|
||||||
|
|
||||||
|
# Puerto personalizado
|
||||||
|
inspect_ssl_cert example.com:8443
|
||||||
|
|
||||||
|
# API interna
|
||||||
|
inspect_ssl_cert api.internal.example.com:4443
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `openssl` y `timeout`. Usa `openssl s_client` con SNI (`-servername`) para soportar virtual hosting. La alerta de expiración se activa a 30 días o menos. La detección de TLS 1.0/1.1 usa flags `-tls1` y `-tls1_1` de openssl s_client — si el servidor acepta la conexión y negocia un cipher, el protocolo inseguro está habilitado. Cada conexión tiene timeout de 10 segundos para evitar cuelgues en hosts sin respuesta.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# inspect_ssl_cert
|
||||||
|
# ----------------
|
||||||
|
# Inspecciona el certificado SSL/TLS de un host: sujeto, emisor, fechas de validez,
|
||||||
|
# SANs, cadena de confianza y versiones de TLS aceptadas (detecta TLS 1.0/1.1 inseguros).
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# inspect_ssl_cert <host[:puerto]>
|
||||||
|
# inspect_ssl_cert example.com
|
||||||
|
# inspect_ssl_cert example.com:8443
|
||||||
|
#
|
||||||
|
# Depende de: openssl, timeout
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Constantes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SSL_WARN_DAYS=30
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssl_parse_host_port() {
|
||||||
|
local input="$1"
|
||||||
|
if [[ "$input" =~ ^(.+):([0-9]+)$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
echo "${input} 443"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_days_until_expiry() {
|
||||||
|
local expiry_str="$1"
|
||||||
|
local expiry_epoch
|
||||||
|
expiry_epoch="$(date -d "$expiry_str" +%s 2>/dev/null || echo 0)"
|
||||||
|
local now_epoch
|
||||||
|
now_epoch="$(date +%s)"
|
||||||
|
echo $(( (expiry_epoch - now_epoch) / 86400 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ssl_fetch_subject() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -subject -issuer 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_dates() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -dates 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_san() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext subjectAltName 2>/dev/null \
|
||||||
|
| grep -oE 'DNS:[^,]+' | sed 's/DNS://g' | tr '\n' ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_fetch_chain() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
echo | timeout 10 openssl s_client -connect "${host}:${port}" -servername "$host" \
|
||||||
|
-showcerts 2>/dev/null \
|
||||||
|
| grep -E "^(subject|issuer)=" | sed 's/^/ /'
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssl_check_tls_version() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
local proto="$3"
|
||||||
|
local label="$4"
|
||||||
|
if echo | timeout 5 openssl s_client -connect "${host}:${port}" \
|
||||||
|
-servername "$host" "${proto}" 2>/dev/null | grep -q "Cipher"; then
|
||||||
|
echo -e " ${RED}[x]${NC} ${label} -- soportado (inseguro)"
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[ok]${NC} ${label} no soportado"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inspect_ssl_cert() {
|
||||||
|
local input="$1"
|
||||||
|
|
||||||
|
if [[ -z "$input" ]]; then
|
||||||
|
error "inspect_ssl_cert: se requiere un host como argumento (ej: example.com o example.com:8443)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "inspect_ssl_cert: 'openssl' no está instalado (sudo apt install openssl)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local host port
|
||||||
|
read -r host port <<< "$(_ssl_parse_host_port "$input")"
|
||||||
|
|
||||||
|
info "Conectando a ${host}:${port}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local subj_issuer
|
||||||
|
subj_issuer="$(_ssl_fetch_subject "$host" "$port")"
|
||||||
|
if [[ -z "$subj_issuer" ]]; then
|
||||||
|
error "inspect_ssl_cert: no se pudo obtener el certificado. ¿El host está disponible?" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local subject issuer
|
||||||
|
subject="$(echo "$subj_issuer" | grep ^subject | cut -d= -f2- | xargs)"
|
||||||
|
issuer="$(echo "$subj_issuer" | grep ^issuer | cut -d= -f2- | xargs)"
|
||||||
|
|
||||||
|
local dates
|
||||||
|
dates="$(_ssl_fetch_dates "$host" "$port")"
|
||||||
|
local not_before not_after
|
||||||
|
not_before="$(echo "$dates" | grep notBefore | cut -d= -f2)"
|
||||||
|
not_after="$(echo "$dates" | grep notAfter | cut -d= -f2)"
|
||||||
|
|
||||||
|
local days_left
|
||||||
|
days_left="$(_ssl_days_until_expiry "$not_after")"
|
||||||
|
|
||||||
|
local sans
|
||||||
|
sans="$(_ssl_fetch_san "$host" "$port")"
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════ Certificado SSL/TLS ════════════════════${NC}"
|
||||||
|
echo -e " ${CYAN}Sujeto:${NC} ${subject}"
|
||||||
|
echo -e " ${CYAN}Emisor:${NC} ${issuer}"
|
||||||
|
echo -e " ${CYAN}Válido desde:${NC} ${not_before}"
|
||||||
|
echo -e " ${CYAN}Válido hasta:${NC} ${not_after}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $days_left -le 0 ]]; then
|
||||||
|
echo -e " ${RED}[x] CERTIFICADO EXPIRADO${NC}"
|
||||||
|
elif [[ $days_left -le $_SSL_WARN_DAYS ]]; then
|
||||||
|
echo -e " ${YELLOW}[!] Expira en ${days_left} días -- renovar pronto${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}[ok] Válido -- expira en ${days_left} días${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}SANs:${NC}"
|
||||||
|
echo "$sans" | tr ' ' '\n' | grep -v '^$' | while IFS= read -r san; do
|
||||||
|
echo -e " ${GREEN}*${NC} ${san}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Cadena de confianza ════════════════════${NC}"
|
||||||
|
_ssl_fetch_chain "$host" "$port"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════ Versiones TLS aceptadas ════════════════${NC}"
|
||||||
|
_ssl_check_tls_version "$host" "$port" "-tls1" "TLS 1.0"
|
||||||
|
_ssl_check_tls_version "$host" "$port" "-tls1_1" "TLS 1.1"
|
||||||
|
echo -e " ${GREEN}[ok]${NC} TLS 1.2 / 1.3 (estándar)"
|
||||||
|
echo -e "${PURPLE}═════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
inspect_ssl_cert "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: list_active_connections
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "list_active_connections(mode: string) -> void"
|
||||||
|
description: "Muestra conexiones de red activas del sistema usando ss: puertos en escucha, conexiones establecidas y detección de conexiones hacia IPs externas (excluye RFC1918, loopback y link-local)."
|
||||||
|
tags: [bash, cybersecurity, network, connections, monitoring, ss, ports]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "modo de visualización: listening (puertos en escucha), established (conexiones activas), external (solo IPs externas) o all (todo, por defecto)"
|
||||||
|
output: "imprime tabla de conexiones de red a stdout con colores ANSI; las conexiones a IPs externas se resaltan en amarillo"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/list_active_connections.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/redes/conexiones_activas.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/list_active_connections.sh
|
||||||
|
|
||||||
|
# Todas las conexiones
|
||||||
|
list_active_connections
|
||||||
|
|
||||||
|
# Solo puertos en escucha
|
||||||
|
list_active_connections listening
|
||||||
|
|
||||||
|
# Solo conexiones hacia internet
|
||||||
|
list_active_connections external
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `ss` del paquete iproute2 (disponible por defecto en la mayoría de distribuciones modernas). La detección de IPs externas excluye: 127.x, ::1, 0.0.0.0, rangos RFC1918 (10.x, 172.16-31.x, 192.168.x) y link-local (fe80:). Usa `ss -tnp` para mostrar el proceso asociado a cada conexión (puede requerir sudo para ver procesos de otros usuarios).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# list_active_connections
|
||||||
|
# -----------------------
|
||||||
|
# Muestra conexiones de red activas del sistema: puertos en escucha,
|
||||||
|
# conexiones establecidas y detección de IPs externas (no RFC1918).
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# list_active_connections [listening|established|external|all]
|
||||||
|
#
|
||||||
|
# Depende de: ss (iproute2)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_conn_is_external_ip() {
|
||||||
|
local ip="$1"
|
||||||
|
# Devuelve 0 (verdadero) si no es loopback, link-local ni RFC1918
|
||||||
|
[[ ! "$ip" =~ ^(127\.|::1|0\.0\.0\.0|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|fe80:) ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_conn_show_listening() {
|
||||||
|
info "Puertos en escucha con proceso asociado..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
ss -tlnp 2>/dev/null \
|
||||||
|
| awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $7}' \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_conn_show_established() {
|
||||||
|
info "Conexiones establecidas..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
ss -tnp state established 2>/dev/null \
|
||||||
|
| awk 'NR==1 {printf "%-6s %-25s %-25s %s\n", "Proto", "Local", "Peer", "Proceso"} NR>1 {printf "%-6s %-25s %-25s %s\n", $1, $4, $5, $6}' \
|
||||||
|
| while IFS= read -r line; do echo -e " ${DIM_GRAY}${line}${NC}"; done
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_conn_show_external() {
|
||||||
|
info "Conexiones hacia IPs externas..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
local peer
|
||||||
|
peer="$(echo "$line" | awk '{print $5}' | cut -d: -f1)"
|
||||||
|
if _conn_is_external_ip "$peer"; then
|
||||||
|
echo -e " ${YELLOW}*${NC} $line"
|
||||||
|
found=$((found + 1))
|
||||||
|
fi
|
||||||
|
done < <(ss -tnp state established 2>/dev/null | tail -n +2)
|
||||||
|
|
||||||
|
echo -e "${PURPLE}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
success "No se detectaron conexiones hacia IPs externas"
|
||||||
|
else
|
||||||
|
info "Total conexiones externas: $found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
list_active_connections() {
|
||||||
|
local mode="${1:-all}"
|
||||||
|
|
||||||
|
if ! command -v ss &>/dev/null; then
|
||||||
|
error "list_active_connections: 'ss' no está disponible (sudo apt install iproute2)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
listening)
|
||||||
|
_conn_show_listening
|
||||||
|
;;
|
||||||
|
established)
|
||||||
|
_conn_show_established
|
||||||
|
;;
|
||||||
|
external)
|
||||||
|
_conn_show_external
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_conn_show_listening
|
||||||
|
echo ""
|
||||||
|
_conn_show_established
|
||||||
|
echo ""
|
||||||
|
_conn_show_external
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
error "list_active_connections: modo no válido '$mode'. Use: listening|established|external|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
list_active_connections "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: verify_file_hash
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: cybersecurity
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "verify_file_hash(file: string, algorithm: string, expected_hash: string) -> void"
|
||||||
|
description: "Calcula el hash criptográfico de un archivo con el algoritmo especificado (md5, sha1, sha256, sha512) y opcionalmente lo compara con un hash esperado para verificar integridad. Retorna exit code 1 si los hashes no coinciden."
|
||||||
|
tags: [bash, cybersecurity, hash, integrity, checksum, md5, sha256, sha512]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: file
|
||||||
|
desc: "ruta al archivo del que calcular el hash"
|
||||||
|
- name: algorithm
|
||||||
|
desc: "algoritmo de hash a usar: md5, sha1, sha256 (recomendado) o sha512"
|
||||||
|
- name: expected_hash
|
||||||
|
desc: "hash esperado en hexadecimal para verificar integridad (opcional; si se omite, solo calcula e imprime)"
|
||||||
|
output: "imprime el hash calculado; si se proporcionó expected_hash, imprime COINCIDE o NO COINCIDE y retorna exit code 1 si no coinciden"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/cybersecurity/verify_file_hash.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/ciberseguridad/utilidades/verificar_hash.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/cybersecurity/verify_file_hash.sh
|
||||||
|
|
||||||
|
# Solo calcular el hash
|
||||||
|
verify_file_hash archivo.iso sha256
|
||||||
|
|
||||||
|
# Verificar contra un hash conocido
|
||||||
|
verify_file_hash archivo.iso sha256 "a1b2c3d4e5f6..."
|
||||||
|
|
||||||
|
# MD5 (solo para compatibilidad, no recomendado para seguridad)
|
||||||
|
verify_file_hash documento.pdf md5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
La comparación de hashes es case-insensitive (normaliza a minúsculas). SHA256 es el algoritmo recomendado para verificación de integridad. MD5 y SHA1 están deprecados para uso en seguridad pero se incluyen para compatibilidad con sumas publicadas en sistemas legacy. Retorna exit code 1 cuando los hashes no coinciden, lo que permite usar la función en scripts con `set -e`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify_file_hash
|
||||||
|
# ----------------
|
||||||
|
# Calcula el hash de un archivo con el algoritmo indicado (md5, sha1, sha256, sha512)
|
||||||
|
# y opcionalmente lo compara con un hash esperado.
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]
|
||||||
|
#
|
||||||
|
# Depende de: md5sum, sha1sum, sha256sum, sha512sum
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_colors.sh"
|
||||||
|
source "$SCRIPT_DIR/../shell/bash_log.sh"
|
||||||
|
bash_colors
|
||||||
|
bash_log_init
|
||||||
|
|
||||||
|
# ─── Funciones puras ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hash_select_cmd() {
|
||||||
|
local algo="$1"
|
||||||
|
case "$algo" in
|
||||||
|
md5) echo "md5sum" ;;
|
||||||
|
sha1) echo "sha1sum" ;;
|
||||||
|
sha256) echo "sha256sum" ;;
|
||||||
|
sha512) echo "sha512sum" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_hash_hashes_match() {
|
||||||
|
local a="${1,,}"
|
||||||
|
local b="${2,,}"
|
||||||
|
[[ "$a" == "$b" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Funciones de efecto ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_hash_compute() {
|
||||||
|
local cmd="$1"
|
||||||
|
local file="$2"
|
||||||
|
"$cmd" "$file" 2>/dev/null | awk '{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Punto de entrada ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
verify_file_hash() {
|
||||||
|
local file="$1"
|
||||||
|
local algorithm="$2"
|
||||||
|
local expected_hash="${3:-}"
|
||||||
|
|
||||||
|
if [[ -z "$file" || -z "$algorithm" ]]; then
|
||||||
|
error "verify_file_hash: uso: verify_file_hash <archivo> <md5|sha1|sha256|sha512> [hash_esperado]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
error "verify_file_hash: archivo no encontrado: $file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cmd
|
||||||
|
cmd="$(_hash_select_cmd "$algorithm")"
|
||||||
|
|
||||||
|
if [[ -z "$cmd" ]]; then
|
||||||
|
error "verify_file_hash: algoritmo no válido '$algorithm'. Use: md5|sha1|sha256|sha512" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
error "verify_file_hash: '$cmd' no está disponible" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Calculando ${algorithm^^} de: $(basename "$file")"
|
||||||
|
local hash
|
||||||
|
hash="$(_hash_compute "$cmd" "$file")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Archivo:${NC} ${file}"
|
||||||
|
echo -e " ${CYAN}${algorithm^^}:${NC} ${hash}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -n "$expected_hash" ]]; then
|
||||||
|
if _hash_hashes_match "$hash" "$expected_hash"; then
|
||||||
|
echo -e " ${GREEN}[COINCIDE]${NC} La integridad del archivo es correcta"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}[NO COINCIDE]${NC} El archivo puede estar corrupto o modificado"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Calculado:${NC} ${hash}"
|
||||||
|
echo -e " ${CYAN}Esperado: ${NC} ${expected_hash}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se llama directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
verify_file_hash "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: analyze_disk_space
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "analyze_disk_space([target_dir: string], [mode: string]) -> void"
|
||||||
|
description: "Analiza el uso de espacio en disco. Modos: partitions (df con filtros), top-dirs (du top 10), top-files (find top 20), inodes (df -i), all (todos). Emite advertencias si el uso supera el 90%."
|
||||||
|
tags: [bash, disk, space, analysis, filesystem]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: target_dir
|
||||||
|
desc: "directorio a analizar para top-dirs y top-files (default: /)"
|
||||||
|
- name: mode
|
||||||
|
desc: "qué analizar: partitions|top-dirs|top-files|inodes|all (default: all)"
|
||||||
|
output: "informe de uso de disco a stdout; advertencias a stdout si uso >90%; exit code 1 si modo desconocido"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/analyze_disk_space.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/espacio_disponible.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/analyze_disk_space.sh
|
||||||
|
|
||||||
|
# Análisis completo del directorio raíz
|
||||||
|
analyze_disk_space
|
||||||
|
|
||||||
|
# Solo particiones
|
||||||
|
analyze_disk_space / partitions
|
||||||
|
|
||||||
|
# Top directorios en home
|
||||||
|
analyze_disk_space "$HOME" top-dirs
|
||||||
|
|
||||||
|
# Solo inodos
|
||||||
|
analyze_disk_space / inodes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Excluye tmpfs, devtmpfs y loop de los resultados de df. No realiza ninguna limpieza destructiva. El modo top-files puede tardar en sistemas con muchos archivos.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# analyze_disk_space
|
||||||
|
# ------------------
|
||||||
|
# Analiza el uso de espacio en disco: particiones, directorios más grandes,
|
||||||
|
# archivos más grandes e inodos.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source analyze_disk_space.sh
|
||||||
|
# analyze_disk_space [target_dir] [mode]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# target_dir Directorio a analizar (default: /)
|
||||||
|
# mode Modo de análisis: partitions|top-dirs|top-files|inodes|all (default: all)
|
||||||
|
|
||||||
|
analyze_disk_space() {
|
||||||
|
local target_dir="${1:-/}"
|
||||||
|
local mode="${2:-all}"
|
||||||
|
|
||||||
|
_ads_partitions() {
|
||||||
|
echo "=== Espacio en sistemas de archivos ==="
|
||||||
|
df -h --output=source,fstype,size,used,avail,pcent,target 2>/dev/null \
|
||||||
|
| grep -v "tmpfs\|devtmpfs\|loop" | column -t
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local high_usage
|
||||||
|
high_usage="$(df -h | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
|
||||||
|
if [[ -n "$high_usage" ]]; then
|
||||||
|
echo "ADVERTENCIA: Discos con uso >90%:"
|
||||||
|
echo "$high_usage"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_top_dirs() {
|
||||||
|
local dir="${1:-.}"
|
||||||
|
echo "=== Top 10 carpetas más grandes en: $(realpath "$dir") ==="
|
||||||
|
du -h --max-depth=1 "$dir" 2>/dev/null | sort -rh | head -11 \
|
||||||
|
| awk '{printf "%-10s %s\n", $1, $2}'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_top_files() {
|
||||||
|
local dir="${1:-.}"
|
||||||
|
echo "=== Top 20 archivos más grandes en: $(realpath "$dir") ==="
|
||||||
|
find "$dir" -type f -exec du -h {} + 2>/dev/null | sort -rh | head -20 \
|
||||||
|
| awk '{printf "%-10s %s\n", $1, $2}'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ads_inodes() {
|
||||||
|
echo "=== Inodos disponibles ==="
|
||||||
|
df -i 2>/dev/null | grep -v "tmpfs\|devtmpfs\|loop" | column -t
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local high_inodes
|
||||||
|
high_inodes="$(df -i | awk 'NR>1 && $5+0 > 90 {print $6, $5}' | grep -v "tmpfs\|devtmpfs" || true)"
|
||||||
|
if [[ -n "$high_inodes" ]]; then
|
||||||
|
echo "ADVERTENCIA: Sistemas de archivos con >90% de inodos usados:"
|
||||||
|
echo "$high_inodes"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
partitions)
|
||||||
|
_ads_partitions
|
||||||
|
;;
|
||||||
|
top-dirs)
|
||||||
|
_ads_top_dirs "$target_dir"
|
||||||
|
;;
|
||||||
|
top-files)
|
||||||
|
_ads_top_files "$target_dir"
|
||||||
|
;;
|
||||||
|
inodes)
|
||||||
|
_ads_inodes
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
_ads_partitions
|
||||||
|
_ads_top_dirs "$target_dir"
|
||||||
|
_ads_top_files "$target_dir"
|
||||||
|
_ads_inodes
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "analyze_disk_space: modo desconocido '${mode}'. Usa: partitions|top-dirs|top-files|inodes|all" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
analyze_disk_space "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: detect_wsl
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "detect_wsl([--check]) -> void"
|
||||||
|
description: "Detecta si el sistema es WSL (Windows Subsystem for Linux). Con --check retorna solo exit code (0=WSL, 1=no WSL) sin output. Sin argumentos imprime versión WSL, usuario Windows, distribución, hostname, unidades montadas y ruta Windows del directorio actual."
|
||||||
|
tags: [bash, wsl, windows, detect, integration]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: --check
|
||||||
|
desc: "flag: solo detecta y retorna exit code sin producir output (0=WSL, 1=no WSL)"
|
||||||
|
output: "sin output con --check; informe del entorno WSL a stdout sin argumentos; exit code 1 si no es WSL"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/detect_wsl.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/wsl_host.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/detect_wsl.sh
|
||||||
|
|
||||||
|
# Verificar si es WSL en scripts (sin output)
|
||||||
|
if detect_wsl --check; then
|
||||||
|
echo "Estamos en WSL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mostrar información completa del entorno WSL
|
||||||
|
detect_wsl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa tres métodos de detección en orden: /proc/version, /proc/sys/kernel/osrelease, y la presencia de /mnt/c + WSLInterop. No incluye las acciones interactivas del script original (abrir PowerShell, CMD, Explorer, VS Code).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# detect_wsl
|
||||||
|
# ----------
|
||||||
|
# Detecta si el sistema actual es WSL (Windows Subsystem for Linux).
|
||||||
|
# Con --check solo retorna exit code (0=WSL, 1=no WSL) sin output.
|
||||||
|
# Sin argumentos, imprime información completa del entorno WSL.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source detect_wsl.sh
|
||||||
|
# detect_wsl [--check]
|
||||||
|
|
||||||
|
detect_wsl() {
|
||||||
|
local check_only=false
|
||||||
|
[[ "${1:-}" == "--check" ]] && check_only=true
|
||||||
|
|
||||||
|
# Detección interna de WSL
|
||||||
|
_is_wsl() {
|
||||||
|
if [[ -f /proc/version ]] && grep -qi "microsoft\|wsl" /proc/version; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f /proc/sys/kernel/osrelease ]] && grep -qi "microsoft\|wsl" /proc/sys/kernel/osrelease; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -d /mnt/c ]] && [[ -f /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_get_wsl_version() {
|
||||||
|
if [[ -f /proc/version ]]; then
|
||||||
|
if grep -qi "WSL2" /proc/version; then
|
||||||
|
echo "WSL2"
|
||||||
|
elif grep -qi "microsoft" /proc/version; then
|
||||||
|
echo "WSL1"
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_get_windows_username() {
|
||||||
|
if [[ -n "${WSLENV:-}" ]]; then
|
||||||
|
cmd.exe /c "echo %USERNAME%" 2>/dev/null | tr -d '\r\n' || echo "Unknown"
|
||||||
|
else
|
||||||
|
echo "Unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modo --check: solo exit code
|
||||||
|
if [[ "$check_only" == true ]]; then
|
||||||
|
_is_wsl && return 0 || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Modo informativo
|
||||||
|
if ! _is_wsl; then
|
||||||
|
echo "detect_wsl: este sistema NO es WSL" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local wsl_version
|
||||||
|
wsl_version="$(_get_wsl_version)"
|
||||||
|
|
||||||
|
local win_user
|
||||||
|
win_user="$(_get_windows_username)"
|
||||||
|
|
||||||
|
local distro="Unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
distro="$(. /etc/os-release && echo "${PRETTY_NAME:-${ID:-Unknown}}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Entorno WSL ==="
|
||||||
|
echo " Versión de WSL: ${wsl_version}"
|
||||||
|
echo " Usuario Windows: ${win_user}"
|
||||||
|
echo " Distribución: ${distro}"
|
||||||
|
echo " Hostname: $(hostname)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Unidades de Windows montadas ==="
|
||||||
|
ls /mnt/ 2>/dev/null | grep -E "^[a-z]$" | while IFS= read -r drive; do
|
||||||
|
echo " ${drive}: → /mnt/${drive}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local current_win_path
|
||||||
|
current_win_path="$(wslpath -w "$(pwd)" 2>/dev/null || echo "N/A")"
|
||||||
|
echo "=== Directorio actual en Windows ==="
|
||||||
|
echo " ${current_win_path}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
detect_wsl "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: docker_compose_remote_deploy
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "docker_compose_remote_deploy(host: string, remote_dir: string, branch: string, compose_files: string) -> json"
|
||||||
|
description: "Despliega un stack Docker Compose en un host remoto via SSH. Verifica conectividad, hace git pull del branch indicado, actualiza imagenes con docker-compose pull y levanta/recrea los servicios modificados con docker-compose up -d. Soporta compose files adicionales. Retorna JSON con status, containers corriendo y duracion."
|
||||||
|
tags: [docker, compose, deploy, ssh, remote, git, infra, cicd]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
desc: "alias SSH del host remoto definido en ~/.ssh/config (ej: prod-server)"
|
||||||
|
- name: remote_dir
|
||||||
|
desc: "ruta absoluta en el host donde esta el repo con docker-compose.yml (ej: /opt/apps/element)"
|
||||||
|
- name: branch
|
||||||
|
desc: "branch de git a hacer pull; default 'main'"
|
||||||
|
- name: compose_files
|
||||||
|
desc: "archivos compose adicionales separados por coma (ej: 'docker-compose.livekit.yml,docker-compose.monitoring.yml'); si vacio usa solo docker-compose.yml"
|
||||||
|
output: "JSON con status ('ok'), host, remote_dir, branch, containers (array de nombres corriendo tras el deploy), duration_ms"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/docker_compose_remote_deploy.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/docker_compose_remote_deploy.sh
|
||||||
|
|
||||||
|
# Deploy basico (solo docker-compose.yml, branch main)
|
||||||
|
result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element")
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"main","containers":["element-web","synapse","postgres"],"duration_ms":4200}
|
||||||
|
|
||||||
|
# Deploy con compose files adicionales y branch especifico
|
||||||
|
result=$(docker_compose_remote_deploy "prod-server" "/opt/apps/element" "release" "docker-compose.livekit.yml,docker-compose.monitoring.yml")
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","host":"prod-server","remote_dir":"/opt/apps/element","branch":"release","containers":[...],"duration_ms":8100}
|
||||||
|
|
||||||
|
# Uso desde un pipeline CI/CD
|
||||||
|
source bash/functions/infra/docker_compose_remote_deploy.sh
|
||||||
|
docker_compose_remote_deploy "$SSH_HOST" "$REMOTE_DIR" "$GIT_BRANCH" "$EXTRA_COMPOSE" || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Flujo: verificar SSH → git pull → docker-compose pull → docker-compose up -d → listar containers.
|
||||||
|
- La verificacion SSH usa `-o BatchMode=yes -o ConnectTimeout=5` para fallar rapido sin pedir password.
|
||||||
|
- Los compose files adicionales se pasan como `-f file1.yml -f file2.yml` a todos los subcomandos compose.
|
||||||
|
- `docker-compose up -d` solo recrea los servicios cuya imagen o config cambio (comportamiento nativo de compose).
|
||||||
|
- La lista de containers al final incluye TODOS los containers corriendo en el host, no solo los del stack.
|
||||||
|
- Requiere `jq` instalado en el host remoto para serializar la lista de containers. Si no esta, `containers` sera `[]`.
|
||||||
|
- Los mensajes de progreso van a stderr; el JSON final va a stdout.
|
||||||
|
- Exit code 1 en cualquier fallo (SSH, git pull, compose pull, compose up); el JSON de error NO se emite — el caller debe manejar el exit code.
|
||||||
|
- El `host` se resuelve con `~/.ssh/config` incluyendo host, user, identityfile y puerto.
|
||||||
|
- Diferencia con `rsync_deploy`: este flujo asume que el codigo ya esta en el remoto (via git) y usa compose. `rsync_deploy` sube archivos locales sin git.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker_compose_remote_deploy — Despliega un stack Docker Compose en un host remoto via SSH
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker_compose_remote_deploy() {
|
||||||
|
local host="$1"
|
||||||
|
local remote_dir="$2"
|
||||||
|
local branch="${3:-main}"
|
||||||
|
local compose_files="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "$host" || -z "$remote_dir" ]]; then
|
||||||
|
echo "docker_compose_remote_deploy: se requieren host y remote_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local start_ts
|
||||||
|
start_ts=$(date +%s)
|
||||||
|
|
||||||
|
# 1. Verificar conectividad SSH
|
||||||
|
echo "docker_compose_remote_deploy: verificando conectividad SSH a '$host'..." >&2
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then
|
||||||
|
echo "docker_compose_remote_deploy: no se puede conectar a '$host' via SSH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Git pull en el host remoto
|
||||||
|
echo "docker_compose_remote_deploy: git pull origin $branch en '$remote_dir'..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && git pull origin '$branch'" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: git pull falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Construir los argumentos -f para docker-compose
|
||||||
|
local compose_args="-f docker-compose.yml"
|
||||||
|
if [[ -n "$compose_files" ]]; then
|
||||||
|
local IFS=","
|
||||||
|
local extra_file
|
||||||
|
for extra_file in $compose_files; do
|
||||||
|
extra_file="${extra_file// /}" # trim spaces
|
||||||
|
if [[ -n "$extra_file" ]]; then
|
||||||
|
compose_args="$compose_args -f $extra_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. docker-compose pull
|
||||||
|
echo "docker_compose_remote_deploy: actualizando imagenes ($compose_args)..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args pull" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: docker-compose pull falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. docker-compose up -d
|
||||||
|
echo "docker_compose_remote_deploy: levantando servicios ($compose_args)..." >&2
|
||||||
|
if ! ssh "$host" "cd '$remote_dir' && docker-compose $compose_args up -d" >&2; then
|
||||||
|
echo "docker_compose_remote_deploy: docker-compose up -d falló en '$host:$remote_dir'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Recopilar containers corriendo tras el deploy
|
||||||
|
local containers_json
|
||||||
|
containers_json=$(ssh "$host" \
|
||||||
|
"docker ps --format '{{.Names}}' 2>/dev/null | jq -R . | jq -sc ." 2>/dev/null || echo '[]')
|
||||||
|
|
||||||
|
local end_ts
|
||||||
|
end_ts=$(date +%s)
|
||||||
|
local duration_ms=$(( (end_ts - start_ts) * 1000 ))
|
||||||
|
|
||||||
|
# Emitir JSON a stdout
|
||||||
|
printf '{"status":"ok","host":"%s","remote_dir":"%s","branch":"%s","containers":%s,"duration_ms":%d}\n' \
|
||||||
|
"$host" \
|
||||||
|
"$remote_dir" \
|
||||||
|
"$branch" \
|
||||||
|
"$containers_json" \
|
||||||
|
"$duration_ms"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
docker_compose_remote_deploy "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: gitea_create_webhook
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_create_webhook(owner: string, repo: string, target_url: string, secret?: string) -> json"
|
||||||
|
description: "Crea un webhook de push en un repositorio Gitea. El webhook notifica a target_url en cada push."
|
||||||
|
tags: [gitea, webhook, push, deploy, ci, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: owner
|
||||||
|
desc: "usuario u organización propietaria del repositorio"
|
||||||
|
- name: repo
|
||||||
|
desc: "nombre del repositorio"
|
||||||
|
- name: target_url
|
||||||
|
desc: "URL que recibirá el POST del webhook en cada push"
|
||||||
|
- name: secret
|
||||||
|
desc: "secreto compartido para firmar el payload (opcional)"
|
||||||
|
output: "JSON con webhook_id, owner, repo, target_url"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_create_webhook.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_create_webhook.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Crear webhook para auto-deploy
|
||||||
|
gitea_create_webhook "myorg" "dag_engine" "http://vps:9090/webhook/push" "mi_secreto"
|
||||||
|
# {"webhook_id":42,"owner":"myorg","repo":"dag_engine","target_url":"http://vps:9090/webhook/push"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere `GITEA_URL` y `GITEA_TOKEN` como variables de entorno.
|
||||||
|
- Solo escucha eventos `push`. Para otros eventos, modificar el array `events` en el payload.
|
||||||
|
- Si el webhook ya existe para la misma URL, Gitea crea uno duplicado (no es idempotente).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_create_webhook — Crea un webhook de push en un repositorio Gitea
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
gitea_create_webhook() {
|
||||||
|
local owner="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local target_url="$3"
|
||||||
|
local secret="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "$owner" || -z "$repo" || -z "$target_url" ]]; then
|
||||||
|
echo "usage: gitea_create_webhook <owner> <repo> <target_url> [secret]" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local gitea_url="${GITEA_URL:?GITEA_URL no seteada}"
|
||||||
|
local gitea_token="${GITEA_TOKEN:?GITEA_TOKEN no seteada}"
|
||||||
|
|
||||||
|
# Payload JSON para el webhook
|
||||||
|
local payload
|
||||||
|
payload=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"type": "gitea",
|
||||||
|
"active": true,
|
||||||
|
"events": ["push"],
|
||||||
|
"config": {
|
||||||
|
"url": "$target_url",
|
||||||
|
"content_type": "json",
|
||||||
|
"secret": "$secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
local response http_code body
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $gitea_token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${gitea_url}/api/v1/repos/${owner}/${repo}/hooks")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
|
||||||
|
# Extraer webhook ID del response
|
||||||
|
local webhook_id
|
||||||
|
webhook_id=$(echo "$body" | grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||||
|
printf '{"webhook_id":%s,"owner":"%s","repo":"%s","target_url":"%s"}\n' \
|
||||||
|
"$webhook_id" "$owner" "$repo" "$target_url"
|
||||||
|
else
|
||||||
|
echo "gitea_create_webhook: HTTP $http_code — $body" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
gitea_create_webhook "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: install_go
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_go([version: string], [--force]) -> void"
|
||||||
|
description: "Instala Go en Linux descargando desde go.dev/dl. Detecta arquitectura automáticamente (amd64/arm64/armv6l). Idempotente: omite la instalación si Go ya está presente (a menos que se use --force). Configura PATH en ~/.bashrc o ~/.zshrc."
|
||||||
|
tags: [bash, install, go, golang]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: version
|
||||||
|
desc: "versión de Go a instalar, ej: 1.22.0 (default: 1.22.0)"
|
||||||
|
- name: --force
|
||||||
|
desc: "flag para reinstalar aunque Go ya esté instalado"
|
||||||
|
output: "progreso a stdout; exit code 1 si la arquitectura no es soportada o falla la descarga"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_go.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_go.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_go.sh
|
||||||
|
|
||||||
|
# Instalar versión por defecto (1.22.0)
|
||||||
|
install_go
|
||||||
|
|
||||||
|
# Instalar versión específica
|
||||||
|
install_go 1.23.0
|
||||||
|
|
||||||
|
# Reinstalar aunque ya esté instalado
|
||||||
|
install_go 1.22.0 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl` y `sudo`. Instala en `/usr/local/go`. Crea `$HOME/go/{bin,src,pkg}` como GOPATH. Después de instalar, hay que recargar el shell (`source ~/.bashrc`) o abrir una nueva terminal.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_go
|
||||||
|
# ----------
|
||||||
|
# Instala Go en Linux. Detecta arquitectura automáticamente (amd64/arm64/armv6l).
|
||||||
|
# Descarga desde go.dev/dl, instala en /usr/local y configura PATH en el shell config.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_go.sh
|
||||||
|
# install_go [version] [--force]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# version Versión de Go a instalar (default: 1.22.0)
|
||||||
|
# --force Reinstala aunque Go ya esté instalado
|
||||||
|
|
||||||
|
install_go() {
|
||||||
|
local version="1.22.0"
|
||||||
|
local force=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) force=true ;;
|
||||||
|
*) [[ "$arg" =~ ^[0-9] ]] && version="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local go_os="linux"
|
||||||
|
local go_arch
|
||||||
|
|
||||||
|
# Detectar arquitectura
|
||||||
|
local arch
|
||||||
|
arch="$(uname -m)"
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) go_arch="amd64" ;;
|
||||||
|
aarch64|arm64) go_arch="arm64" ;;
|
||||||
|
armv6l) go_arch="armv6l" ;;
|
||||||
|
*)
|
||||||
|
echo "install_go: arquitectura no soportada: ${arch}" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Verificar si ya está instalado
|
||||||
|
if command -v go &>/dev/null && [[ "$force" != true ]]; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(go version | awk '{print $3}' | sed 's/go//')"
|
||||||
|
echo "install_go: Go ya está instalado (versión: ${current_version}). Usa --force para reinstalar."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tarball="go${version}.${go_os}-${go_arch}.tar.gz"
|
||||||
|
local url="https://go.dev/dl/${tarball}"
|
||||||
|
local install_dir="/usr/local"
|
||||||
|
|
||||||
|
echo "Instalando Go ${version} para ${go_os}-${go_arch}..."
|
||||||
|
|
||||||
|
# Eliminar versión anterior si existe
|
||||||
|
if command -v go &>/dev/null; then
|
||||||
|
echo "Eliminando versión anterior..."
|
||||||
|
sudo rm -rf "${install_dir}/go"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Descargar en directorio temporal
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
||||||
|
echo "Descargando ${url}..."
|
||||||
|
if ! curl -LO --output-dir "$tmp_dir" "$url"; then
|
||||||
|
echo "install_go: error al descargar Go ${version}. Verifica la versión en: https://go.dev/dl/" >&2
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando en ${install_dir}..."
|
||||||
|
sudo tar -C "$install_dir" -xzf "${tmp_dir}/${tarball}"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
|
||||||
|
# Configurar PATH en shell config
|
||||||
|
local shell_config=""
|
||||||
|
if [[ -f "$HOME/.bashrc" ]]; then
|
||||||
|
shell_config="$HOME/.bashrc"
|
||||||
|
elif [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
shell_config="$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$shell_config" ]]; then
|
||||||
|
if ! grep -q "export PATH=\$PATH:${install_dir}/go/bin" "$shell_config"; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Go configuration"
|
||||||
|
echo "export PATH=\$PATH:${install_dir}/go/bin"
|
||||||
|
echo "export GOPATH=\$HOME/go"
|
||||||
|
echo "export PATH=\$PATH:\$GOPATH/bin"
|
||||||
|
} >> "$shell_config"
|
||||||
|
echo "Variables de PATH añadidas a ${shell_config}"
|
||||||
|
else
|
||||||
|
echo "Variables de entorno ya configuradas en ${shell_config}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear estructura GOPATH
|
||||||
|
mkdir -p "$HOME/go"/{bin,src,pkg}
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
export PATH="$PATH:${install_dir}/go/bin"
|
||||||
|
local installed_version
|
||||||
|
installed_version="$("${install_dir}/go/bin/go" version)"
|
||||||
|
echo ""
|
||||||
|
echo "Go instalado correctamente: ${installed_version}"
|
||||||
|
echo "Reinicia tu terminal o ejecuta: source ${shell_config:-~/.bashrc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_go "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_nodejs
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_nodejs([version: string]) -> void"
|
||||||
|
description: "Instala Node.js en Linux usando nvm. Instala nvm v0.39.7 si no está presente. Instala la versión de Node indicada, la activa con 'nvm use' y la configura como default. Idempotente si nvm ya está instalado."
|
||||||
|
tags: [bash, install, nodejs, nvm]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: version
|
||||||
|
desc: "versión principal de Node.js a instalar (default: 20)"
|
||||||
|
output: "progreso a stdout con versión instalada; exit code 1 si nvm no queda disponible"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_nodejs.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_nodejs.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_nodejs.sh
|
||||||
|
|
||||||
|
# Instalar Node.js 20 (LTS por defecto)
|
||||||
|
install_nodejs
|
||||||
|
|
||||||
|
# Instalar versión específica
|
||||||
|
install_nodejs 18
|
||||||
|
install_nodejs 21
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl`. nvm se instala en `$HOME/.nvm`. Después de instalar en una sesión nueva, hay que recargar el shell para que los comandos `node` y `npm` queden disponibles globalmente.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_nodejs
|
||||||
|
# --------------
|
||||||
|
# Instala Node.js en Linux usando nvm (Node Version Manager).
|
||||||
|
# Instala nvm si no está presente, luego instala la versión de Node indicada
|
||||||
|
# y la configura como default.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_nodejs.sh
|
||||||
|
# install_nodejs [version]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# version Versión principal de Node.js (default: 20)
|
||||||
|
|
||||||
|
install_nodejs() {
|
||||||
|
local node_version="${1:-20}"
|
||||||
|
|
||||||
|
echo "Instalando Node.js v${node_version} mediante nvm..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Informar si Node ya está instalado
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(node --version)"
|
||||||
|
echo "Node.js ya está instalado: ${current_version}"
|
||||||
|
echo "Continuando con la instalación/actualización..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Instalar nvm si no está presente
|
||||||
|
if [[ -d "$HOME/.nvm" ]]; then
|
||||||
|
echo "nvm ya está instalado."
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
|
||||||
|
else
|
||||||
|
echo "Descargando e instalando nvm..."
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
|
||||||
|
echo "nvm instalado correctamente."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que nvm esté disponible
|
||||||
|
if ! command -v nvm &>/dev/null; then
|
||||||
|
echo "install_nodejs: nvm no está disponible después de la instalación" >&2
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Instalando Node.js v${node_version}..."
|
||||||
|
nvm install "$node_version"
|
||||||
|
nvm use "$node_version"
|
||||||
|
nvm alias default "$node_version"
|
||||||
|
|
||||||
|
local installed_node
|
||||||
|
local installed_npm
|
||||||
|
installed_node="$(node --version)"
|
||||||
|
installed_npm="$(npm --version)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Node.js instalado correctamente:"
|
||||||
|
echo " Node.js: ${installed_node}"
|
||||||
|
echo " npm: ${installed_npm}"
|
||||||
|
echo ""
|
||||||
|
echo "Si es una instalación nueva, reinicia tu terminal o ejecuta:"
|
||||||
|
echo " source ~/.bashrc # o ~/.zshrc según tu shell"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_nodejs "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: install_pnpm
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_pnpm() -> void"
|
||||||
|
description: "Instala pnpm globalmente usando npm (npm install -g pnpm). Verifica que npm esté disponible. Idempotente: si pnpm ya está instalado, informa y termina sin hacer nada."
|
||||||
|
tags: [bash, install, pnpm, node]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout con versión instalada; exit code 1 si npm no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_pnpm.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_pnpm.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_pnpm.sh
|
||||||
|
|
||||||
|
install_pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere Node.js/npm instalado previamente. Si la instalación global falla por permisos, usar `sudo npm install -g pnpm` manualmente. Idempotente: vuelve a ejecutarse sin error si pnpm ya existe.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_pnpm
|
||||||
|
# ------------
|
||||||
|
# Instala pnpm globalmente usando npm.
|
||||||
|
# Verifica que npm esté disponible antes de instalar.
|
||||||
|
# Idempotente: informa si pnpm ya está instalado.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_pnpm.sh
|
||||||
|
# install_pnpm
|
||||||
|
|
||||||
|
install_pnpm() {
|
||||||
|
echo "Instalando pnpm..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si pnpm ya está instalado
|
||||||
|
if command -v pnpm &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(pnpm --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "pnpm ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar que npm esté disponible
|
||||||
|
if ! command -v npm &>/dev/null; then
|
||||||
|
echo "install_pnpm: npm no está instalado (requerido para instalar pnpm)" >&2
|
||||||
|
echo " Instala Node.js primero con install_nodejs" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local npm_version
|
||||||
|
npm_version="$(npm --version 2>/dev/null || echo "?")"
|
||||||
|
echo "npm detectado: ${npm_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Instalando pnpm globalmente (npm install -g pnpm)..."
|
||||||
|
if ! npm install -g pnpm; then
|
||||||
|
echo "install_pnpm: falló la instalación de pnpm" >&2
|
||||||
|
echo " Intenta con sudo: sudo npm install -g pnpm" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v pnpm &>/dev/null; then
|
||||||
|
echo "install_pnpm: pnpm no está disponible después de la instalación" >&2
|
||||||
|
echo " Verifica que npm/bin esté en tu PATH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(pnpm --version)"
|
||||||
|
echo ""
|
||||||
|
echo "pnpm instalado correctamente: ${installed_version}"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " pnpm install - Instalar dependencias"
|
||||||
|
echo " pnpm add <pkg> - Agregar paquete"
|
||||||
|
echo " pnpm run <cmd> - Ejecutar script"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_pnpm "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: install_python312
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_python312() -> void"
|
||||||
|
description: "Instala Python 3.12 detectando la distribución Linux automáticamente. Ubuntu/Debian/Mint usan deadsnakes PPA; Fedora/RHEL usan dnf; Arch/Manjaro usan pacman. Instala también python3.12-venv, python3.12-dev y verifica pip. Idempotente."
|
||||||
|
tags: [bash, install, python, python312]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos; detecta la distribución automáticamente"
|
||||||
|
output: "progreso a stdout; exit code 1 si la distribución no es soportada o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_python312.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_python312.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_python312.sh
|
||||||
|
|
||||||
|
install_python312
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `sudo`. Para distribuciones no soportadas, se recomienda usar pyenv. Idempotente: si `python3.12` ya existe en PATH, informa y termina sin hacer nada.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_python312
|
||||||
|
# -----------------
|
||||||
|
# Instala Python 3.12 en Linux detectando la distribución automáticamente.
|
||||||
|
# - Ubuntu/Debian/Pop/Mint/Elementary: usa deadsnakes PPA
|
||||||
|
# - Fedora/RHEL/CentOS: usa dnf
|
||||||
|
# - Arch/Manjaro: usa pacman
|
||||||
|
# Instala también python3.12-venv, python3.12-dev y verifica pip.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_python312.sh
|
||||||
|
# install_python312
|
||||||
|
|
||||||
|
install_python312() {
|
||||||
|
echo "Instalando Python 3.12..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detectar distribución
|
||||||
|
local distro="unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
distro="$(. /etc/os-release && echo "${ID:-unknown}")"
|
||||||
|
echo "Distribución detectada: ${distro}"
|
||||||
|
else
|
||||||
|
echo "install_python312: no se pudo detectar la distribución" >&2
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si Python 3.12 ya está instalado
|
||||||
|
if command -v python3.12 &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(python3.12 --version 2>&1)"
|
||||||
|
echo "Python 3.12 ya está instalado: ${current_version}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$distro" in
|
||||||
|
ubuntu|debian|pop|mint|elementary)
|
||||||
|
echo "Instalando Python 3.12 usando deadsnakes PPA..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Actualizando repositorios..."
|
||||||
|
if ! sudo apt update; then
|
||||||
|
echo "install_python312: falló la actualización de repositorios" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Verificando software-properties-common..."
|
||||||
|
if ! dpkg -l 2>/dev/null | grep -q software-properties-common; then
|
||||||
|
if ! sudo apt install -y software-properties-common; then
|
||||||
|
echo "install_python312: falló la instalación de software-properties-common" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Añadiendo deadsnakes PPA..."
|
||||||
|
if ! sudo add-apt-repository -y ppa:deadsnakes/ppa; then
|
||||||
|
echo "install_python312: falló al añadir deadsnakes PPA" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Actualizando lista de paquetes..."
|
||||||
|
if ! sudo apt update; then
|
||||||
|
echo "install_python312: falló la actualización después de añadir PPA" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando Python 3.12 y herramientas..."
|
||||||
|
if ! sudo apt install -y python3.12 python3.12-venv python3.12-dev python3-pip; then
|
||||||
|
echo "install_python312: falló la instalación de Python 3.12" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
fedora|rhel|centos)
|
||||||
|
echo "Instalando Python 3.12 usando dnf..."
|
||||||
|
if ! sudo dnf install -y python3.12 python3.12-devel; then
|
||||||
|
echo "install_python312: falló la instalación con dnf" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
arch|manjaro)
|
||||||
|
echo "Instalando Python 3.12 usando pacman..."
|
||||||
|
if ! sudo pacman -S --noconfirm python; then
|
||||||
|
echo "install_python312: falló la instalación con pacman" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "install_python312: distribución no soportada automáticamente: ${distro}" >&2
|
||||||
|
echo " Opciones manuales:" >&2
|
||||||
|
echo " - Compilar desde fuente: https://www.python.org/downloads/" >&2
|
||||||
|
echo " - Usar pyenv: curl https://pyenv.run | bash" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v python3.12 &>/dev/null; then
|
||||||
|
echo "install_python312: Python 3.12 no está disponible después de la instalación" >&2
|
||||||
|
echo " Puede que necesites reiniciar la terminal" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(python3.12 --version 2>&1)"
|
||||||
|
echo "Python 3.12 instalado correctamente: ${installed_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar pip
|
||||||
|
echo "Verificando pip para Python 3.12..."
|
||||||
|
if ! python3.12 -m pip --version &>/dev/null; then
|
||||||
|
echo "pip no disponible, instalando..."
|
||||||
|
if ! python3.12 -m ensurepip --upgrade; then
|
||||||
|
echo " Instala pip manualmente: curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12"
|
||||||
|
else
|
||||||
|
echo "pip instalado para Python 3.12"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "pip disponible para Python 3.12"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " python3.12 -m venv .venv - Crear entorno virtual"
|
||||||
|
echo " source .venv/bin/activate - Activar entorno"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_python312 "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_uv
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_uv() -> void"
|
||||||
|
description: "Instala uv, el gestor de paquetes Python ultra-rápido escrito en Rust, usando el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc. Idempotente: si uv ya está instalado, informa y termina."
|
||||||
|
tags: [bash, install, uv, python]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_uv.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_uv.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_uv.sh
|
||||||
|
|
||||||
|
install_uv
|
||||||
|
|
||||||
|
# Uso posterior
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv pip install requests pandas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Instala en `$HOME/.cargo/bin`. Requiere `curl`. uv es compatible con pip pero 10-100x más rápido. Después de instalar en una sesión nueva, hay que recargar el shell.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_uv
|
||||||
|
# ----------
|
||||||
|
# Instala uv — gestor de paquetes Python ultra-rápido escrito en Rust.
|
||||||
|
# Usa el instalador oficial de astral.sh. Configura PATH en ~/.bashrc y ~/.zshrc.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_uv.sh
|
||||||
|
# install_uv
|
||||||
|
|
||||||
|
install_uv() {
|
||||||
|
echo "Instalando uv (gestor de paquetes Python)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si uv ya está instalado
|
||||||
|
if command -v uv &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(uv --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "uv ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar curl
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
echo "install_uv: curl no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo con: sudo apt install curl" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Descargando e instalando uv (instalador oficial astral.sh)..."
|
||||||
|
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
|
||||||
|
echo "install_uv: falló la instalación de uv" >&2
|
||||||
|
echo " Verifica tu conexión a internet y permisos" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configurar PATH en ~/.bashrc
|
||||||
|
if ! grep -q ".cargo/bin" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# uv and cargo binaries"
|
||||||
|
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
|
||||||
|
} >> "$HOME/.bashrc"
|
||||||
|
echo "PATH añadido a ~/.bashrc"
|
||||||
|
else
|
||||||
|
echo "PATH ya configurado en ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar PATH en ~/.zshrc si existe
|
||||||
|
if [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
if ! grep -q ".cargo/bin" "$HOME/.zshrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# uv and cargo binaries"
|
||||||
|
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
|
||||||
|
} >> "$HOME/.zshrc"
|
||||||
|
echo "PATH añadido a ~/.zshrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cargar PATH en la sesión actual
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if ! command -v uv &>/dev/null; then
|
||||||
|
echo "uv instalado pero no está en el PATH actual."
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)"
|
||||||
|
else
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(uv --version)"
|
||||||
|
echo "uv instalado correctamente: ${installed_version}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles de uv:"
|
||||||
|
echo " uv venv - Crear entorno virtual"
|
||||||
|
echo " uv pip install <package> - Instalar paquete"
|
||||||
|
echo " uv pip sync requirements.txt - Sincronizar dependencias"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_uv "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: install_volta
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_volta() -> void"
|
||||||
|
description: "Instala Volta, el gestor de versiones de Node.js, usando el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH en ~/.bashrc y ~/.zshrc. Idempotente: si Volta ya está instalado, informa y termina."
|
||||||
|
tags: [bash, install, volta, node]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos"
|
||||||
|
output: "progreso a stdout; exit code 1 si curl no está disponible o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_volta.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_volta.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_volta.sh
|
||||||
|
|
||||||
|
install_volta
|
||||||
|
|
||||||
|
# Uso posterior (tras recargar shell)
|
||||||
|
volta install node
|
||||||
|
volta install pnpm
|
||||||
|
volta list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Volta se instala en `$HOME/.volta`. Requiere `curl`. A diferencia de nvm, Volta gestiona versiones de Node.js a nivel de proyecto via `package.json`. Después de instalar, recargar el shell con `source ~/.bashrc`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_volta
|
||||||
|
# -------------
|
||||||
|
# Instala Volta — gestor de versiones de Node.js rápido y confiable.
|
||||||
|
# Usa el instalador oficial de get.volta.sh. Configura VOLTA_HOME y PATH
|
||||||
|
# en ~/.bashrc y ~/.zshrc.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_volta.sh
|
||||||
|
# install_volta
|
||||||
|
|
||||||
|
install_volta() {
|
||||||
|
echo "Instalando Volta (gestor de versiones Node.js)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar si Volta ya está instalado
|
||||||
|
if command -v volta &>/dev/null; then
|
||||||
|
local current_version
|
||||||
|
current_version="$(volta --version 2>/dev/null || echo "desconocida")"
|
||||||
|
echo "Volta ya está instalado (versión: ${current_version})."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar curl
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
echo "install_volta: curl no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo con: sudo apt install curl" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Descargando e instalando Volta (instalador oficial)..."
|
||||||
|
if ! curl https://get.volta.sh | bash; then
|
||||||
|
echo "install_volta: falló la instalación de Volta" >&2
|
||||||
|
echo " Verifica tu conexión a internet" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Configurar variables de entorno
|
||||||
|
local volta_home="$HOME/.volta"
|
||||||
|
export VOLTA_HOME="$volta_home"
|
||||||
|
export PATH="$volta_home/bin:$PATH"
|
||||||
|
|
||||||
|
# Configurar en ~/.bashrc
|
||||||
|
if ! grep -q "VOLTA_HOME" "$HOME/.bashrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Volta configuration"
|
||||||
|
echo 'export VOLTA_HOME="$HOME/.volta"'
|
||||||
|
echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
|
||||||
|
} >> "$HOME/.bashrc"
|
||||||
|
echo "Variables añadidas a ~/.bashrc"
|
||||||
|
else
|
||||||
|
echo "Variables ya configuradas en ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar en ~/.zshrc si existe
|
||||||
|
if [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
if ! grep -q "VOLTA_HOME" "$HOME/.zshrc" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Volta configuration"
|
||||||
|
echo 'export VOLTA_HOME="$HOME/.volta"'
|
||||||
|
echo 'export PATH="$VOLTA_HOME/bin:$PATH"'
|
||||||
|
} >> "$HOME/.zshrc"
|
||||||
|
echo "Variables añadidas a ~/.zshrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if command -v volta &>/dev/null; then
|
||||||
|
local installed_version
|
||||||
|
installed_version="$(volta --version)"
|
||||||
|
echo "Volta instalado correctamente: ${installed_version}"
|
||||||
|
elif [[ -f "$HOME/.volta/bin/volta" ]]; then
|
||||||
|
echo "Volta instalado en ${HOME}/.volta/bin pero no está en PATH actual."
|
||||||
|
echo " Ejecuta: source ~/.bashrc (o abre una nueva terminal)"
|
||||||
|
else
|
||||||
|
echo "install_volta: Volta no está disponible después de la instalación" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Próximos pasos:"
|
||||||
|
echo " 1. source ~/.bashrc - Recargar shell"
|
||||||
|
echo " 2. volta install node - Instalar Node.js"
|
||||||
|
echo " 3. volta install pnpm - Instalar pnpm"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos útiles:"
|
||||||
|
echo " volta install node@20 - Instalar Node.js v20"
|
||||||
|
echo " volta list - Ver herramientas instaladas"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_volta "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: install_wails
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_wails() -> void"
|
||||||
|
description: "Instala Wails v2 (framework de apps de escritorio Go). Detecta la distribución Linux e instala las dependencias de sistema (GTK3, WebKit2GTK, build tools) y luego el CLI via 'go install ...@latest'. Requiere Go instalado previamente."
|
||||||
|
tags: [bash, install, wails, desktop]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: "(ninguno)"
|
||||||
|
desc: "no acepta argumentos; detecta la distribución automáticamente"
|
||||||
|
output: "progreso a stdout; exit code 1 si Go no está disponible, no se detecta la distribución, o falla la instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_wails.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/instaladores/instalar_wails.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/install_wails.sh
|
||||||
|
|
||||||
|
install_wails
|
||||||
|
|
||||||
|
# Verificar
|
||||||
|
wails doctor
|
||||||
|
|
||||||
|
# Crear proyecto
|
||||||
|
wails init -n my-desktop-app -t react
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere Go y sudo. Para distribuciones no listadas (opensuse, etc.) instala las dependencias manualmente y luego procede con el CLI. Templates disponibles: vanilla, vue, react, svelte, lit, angular.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_wails
|
||||||
|
# -------------
|
||||||
|
# Instala Wails v2 — framework para aplicaciones de escritorio en Go.
|
||||||
|
# Detecta la distribución Linux e instala las dependencias de sistema necesarias
|
||||||
|
# (GTK3, WebKit2GTK, build tools) y luego instala el CLI de Wails via go install.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source install_wails.sh
|
||||||
|
# install_wails
|
||||||
|
|
||||||
|
install_wails() {
|
||||||
|
echo "Instalando Wails..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar Go
|
||||||
|
if ! command -v go &>/dev/null; then
|
||||||
|
echo "install_wails: Go no está instalado (requerido)" >&2
|
||||||
|
echo " Instálalo primero con install_go" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local go_version
|
||||||
|
go_version="$(go version)"
|
||||||
|
echo "Go detectado: ${go_version}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detectar distribución
|
||||||
|
local distro="unknown"
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
distro="$(. /etc/os-release && echo "${ID:-unknown}")"
|
||||||
|
else
|
||||||
|
echo "install_wails: no se pudo detectar la distribución de Linux" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando dependencias del sistema para ${distro}..."
|
||||||
|
case "$distro" in
|
||||||
|
ubuntu|debian|linuxmint|pop)
|
||||||
|
sudo apt update
|
||||||
|
if ! sudo apt install -y libgtk-3-dev libwebkit2gtk-4.0-dev build-essential pkg-config; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
fedora|rhel|centos)
|
||||||
|
if ! sudo dnf install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkgconfig; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
arch|manjaro)
|
||||||
|
if ! sudo pacman -Sy --noconfirm gtk3 webkit2gtk base-devel; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
opensuse*)
|
||||||
|
if ! sudo zypper install -y gtk3-devel webkit2gtk3-devel gcc-c++ pkg-config; then
|
||||||
|
echo "install_wails: falló la instalación de dependencias de sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Distribución no reconocida: ${distro}"
|
||||||
|
echo "Instala manualmente: gtk3, webkit2gtk, build-essential, pkg-config"
|
||||||
|
echo "Continuando con la instalación de Wails CLI..."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Instalando Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest)..."
|
||||||
|
if ! go install github.com/wailsapp/wails/v2/cmd/wails@latest; then
|
||||||
|
echo "install_wails: falló la instalación del CLI de Wails" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Asegurar que $GOPATH/bin esté en PATH
|
||||||
|
if [[ ":$PATH:" != *":$HOME/go/bin:"* ]]; then
|
||||||
|
local shell_config=""
|
||||||
|
if [[ -f "$HOME/.bashrc" ]]; then
|
||||||
|
shell_config="$HOME/.bashrc"
|
||||||
|
elif [[ -f "$HOME/.zshrc" ]]; then
|
||||||
|
shell_config="$HOME/.zshrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$shell_config" ]]; then
|
||||||
|
if ! grep -q 'export PATH=\$PATH:\$HOME/go/bin' "$shell_config" 2>/dev/null; then
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Go binaries"
|
||||||
|
echo 'export PATH=$PATH:$HOME/go/bin'
|
||||||
|
} >> "$shell_config"
|
||||||
|
echo "PATH de Go añadido a ${shell_config}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
export PATH="$PATH:$HOME/go/bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verificar instalación
|
||||||
|
if command -v wails &>/dev/null; then
|
||||||
|
local wails_version
|
||||||
|
wails_version="$(wails version 2>/dev/null || echo "instalado")"
|
||||||
|
echo "Wails instalado correctamente: ${wails_version}"
|
||||||
|
echo ""
|
||||||
|
echo "Comandos básicos de Wails:"
|
||||||
|
echo " wails init -n myapp -t vanilla - Crear proyecto"
|
||||||
|
echo " wails dev - Modo desarrollo"
|
||||||
|
echo " wails build - Build producción"
|
||||||
|
echo " wails doctor - Verificar instalación"
|
||||||
|
else
|
||||||
|
echo "Wails instalado pero no está en PATH."
|
||||||
|
echo " Reinicia tu terminal o ejecuta: source ~/.bashrc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_wails "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: list_listening_ports
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "list_listening_ports([mode: string]) -> void"
|
||||||
|
description: "Lista puertos activos del sistema usando ss (preferido) o netstat como fallback. Modos: all (LISTEN), tcp, udp, established (conexiones activas), stats (resumen + interfaces). Imprime salida tabulada a stdout."
|
||||||
|
tags: [bash, ports, network, listening, monitoring]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: mode
|
||||||
|
desc: "qué listar: all|tcp|udp|established|stats (default: all)"
|
||||||
|
output: "tabla de puertos/conexiones a stdout; exit code 1 si no hay ss ni netstat, o si el modo es desconocido"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/list_listening_ports.sh"
|
||||||
|
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "scripts/linux/gestion_linux/puertos_activos.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/list_listening_ports.sh
|
||||||
|
|
||||||
|
# Todos los puertos en escucha
|
||||||
|
list_listening_ports
|
||||||
|
|
||||||
|
# Solo TCP
|
||||||
|
list_listening_ports tcp
|
||||||
|
|
||||||
|
# Conexiones establecidas
|
||||||
|
list_listening_ports established
|
||||||
|
|
||||||
|
# Estadísticas e interfaces
|
||||||
|
list_listening_ports stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Prefiere `ss` (iproute2) sobre `netstat` (net-tools). El modo `established` limita a 30 filas para no saturar el terminal. No incluye monitor en tiempo real (solo snapshot).
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# list_listening_ports
|
||||||
|
# --------------------
|
||||||
|
# Lista puertos activos en el sistema usando ss o netstat.
|
||||||
|
# Soporta filtrado por protocolo y estadísticas de red.
|
||||||
|
#
|
||||||
|
# USO:
|
||||||
|
# source list_listening_ports.sh
|
||||||
|
# list_listening_ports [mode]
|
||||||
|
#
|
||||||
|
# ARGUMENTOS:
|
||||||
|
# mode Modo de listado: all|tcp|udp|established|stats (default: all)
|
||||||
|
|
||||||
|
list_listening_ports() {
|
||||||
|
local mode="${1:-all}"
|
||||||
|
|
||||||
|
_has_ss() {
|
||||||
|
command -v ss &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_has_netstat() {
|
||||||
|
command -v netstat &>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
_require_net_tool() {
|
||||||
|
if ! _has_ss && ! _has_netstat; then
|
||||||
|
echo "list_listening_ports: no se encontró ss ni netstat en el sistema" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_all() {
|
||||||
|
echo "=== Puertos en escucha (LISTEN) ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tulnp 2>/dev/null | awk 'NR==1 || /LISTEN/ {print}' | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tulnp 2>/dev/null | awk 'NR<=2 || /LISTEN/ {print}' | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_tcp() {
|
||||||
|
echo "=== Puertos TCP ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tnlp 2>/dev/null | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tnlp 2>/dev/null | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_udp() {
|
||||||
|
echo "=== Puertos UDP ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -unlp 2>/dev/null | column -t
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -unlp 2>/dev/null | column -t
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_established() {
|
||||||
|
echo "=== Conexiones TCP establecidas ==="
|
||||||
|
local count=0
|
||||||
|
if _has_ss; then
|
||||||
|
ss -tnp 2>/dev/null | awk 'NR==1 || /ESTAB/ {print}' | column -t | head -30
|
||||||
|
count="$(ss -tnp 2>/dev/null | grep -c ESTAB || echo 0)"
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -tnp 2>/dev/null | awk 'NR<=2 || /ESTABLISHED/ {print}' | column -t | head -30
|
||||||
|
count="$(netstat -tnp 2>/dev/null | grep -c ESTABLISHED || echo 0)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Total de conexiones establecidas: ${count}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_llp_stats() {
|
||||||
|
echo "=== Estadísticas de red ==="
|
||||||
|
if _has_ss; then
|
||||||
|
ss -s 2>/dev/null
|
||||||
|
echo ""
|
||||||
|
echo "=== Interfaces de red ==="
|
||||||
|
ip -br addr 2>/dev/null || ifconfig -a 2>/dev/null || echo "No se pudo obtener info de interfaces"
|
||||||
|
elif _has_netstat; then
|
||||||
|
netstat -s 2>/dev/null | head -50
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_require_net_tool || return 1
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
all) _llp_all ;;
|
||||||
|
tcp) _llp_tcp ;;
|
||||||
|
udp) _llp_udp ;;
|
||||||
|
established) _llp_established ;;
|
||||||
|
stats) _llp_stats ;;
|
||||||
|
*)
|
||||||
|
echo "list_listening_ports: modo desconocido '${mode}'. Usa: all|tcp|udp|established|stats" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar si se invoca directamente
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
list_listening_ports "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: rsync_deploy
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "rsync_deploy(local_dir: string, ssh_alias: string, remote_dir: string) -> json"
|
||||||
|
description: "Sincroniza un directorio local a un host remoto via rsync+SSH. Excluye archivos de desarrollo y bases de datos locales. Crea el directorio remoto si no existe."
|
||||||
|
tags: [rsync, deploy, sync, ssh, remote, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: local_dir
|
||||||
|
desc: "ruta al directorio local a sincronizar (ej: apps/dag_engine/)"
|
||||||
|
- name: ssh_alias
|
||||||
|
desc: "alias SSH del host destino definido en ~/.ssh/config (ej: myserver)"
|
||||||
|
- name: remote_dir
|
||||||
|
desc: "ruta absoluta del directorio destino en el host remoto (ej: /opt/apps/dag_engine)"
|
||||||
|
output: "JSON con files_transferred (int), total_size (string), ssh_alias (string), remote_dir (string)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/rsync_deploy.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/rsync_deploy.sh
|
||||||
|
|
||||||
|
# Deploy de una app al servidor de producción
|
||||||
|
result=$(rsync_deploy "apps/dag_engine/" "prod-server" "/opt/apps/dag_engine")
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Usa `rsync -avz --delete`: archivos borrados localmente se borran también en el remoto.
|
||||||
|
- Antes del rsync crea el directorio remoto con `ssh mkdir -p` para evitar errores si no existe.
|
||||||
|
- Archivos excluidos: `.git`, `operations.db*`, `*.exe`, `node_modules`, `.venv`, `__pycache__`, `build/`, `*.db-shm`, `*.db-wal`.
|
||||||
|
- El JSON de salida va a stdout; los mensajes de progreso y errores van a stderr.
|
||||||
|
- Exit code 1 si rsync falla o si el directorio local no existe.
|
||||||
|
- El `ssh_alias` se resuelve con la configuración de `~/.ssh/config`, incluyendo host, user, identityfile y puerto.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rsync_deploy — Sincroniza un directorio local a un host remoto via rsync+SSH
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
rsync_deploy() {
|
||||||
|
local local_dir="$1"
|
||||||
|
local ssh_alias="$2"
|
||||||
|
local remote_dir="$3"
|
||||||
|
|
||||||
|
if [[ -z "$local_dir" || -z "$ssh_alias" || -z "$remote_dir" ]]; then
|
||||||
|
echo "rsync_deploy: se requieren local_dir, ssh_alias y remote_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$local_dir" ]]; then
|
||||||
|
echo "rsync_deploy: directorio local '$local_dir' no existe" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear directorio remoto si no existe
|
||||||
|
echo "rsync_deploy: verificando directorio remoto '$remote_dir' en '$ssh_alias'..." >&2
|
||||||
|
if ! ssh "$ssh_alias" "mkdir -p '$remote_dir'" 2>&1; then
|
||||||
|
echo "rsync_deploy: no se pudo crear el directorio remoto '$remote_dir' en '$ssh_alias'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ejecutar rsync y capturar salida para parsear estadísticas
|
||||||
|
local rsync_output
|
||||||
|
rsync_output=$(rsync -avz --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='operations.db*' \
|
||||||
|
--exclude='*.exe' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.venv' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='*.db-shm' \
|
||||||
|
--exclude='*.db-wal' \
|
||||||
|
-e ssh \
|
||||||
|
"$local_dir" \
|
||||||
|
"${ssh_alias}:${remote_dir}" 2>&1) || {
|
||||||
|
echo "rsync_deploy: rsync falló al sincronizar '$local_dir' → '${ssh_alias}:${remote_dir}'" >&2
|
||||||
|
echo "$rsync_output" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "$rsync_output" >&2
|
||||||
|
|
||||||
|
# Parsear número de archivos transferidos
|
||||||
|
local files_transferred
|
||||||
|
files_transferred=$(echo "$rsync_output" | grep -oP 'Number of regular files transferred: \K[0-9,]+' | tr -d ',' || echo "0")
|
||||||
|
if [[ -z "$files_transferred" ]]; then
|
||||||
|
# Intentar formato alternativo de rsync
|
||||||
|
files_transferred=$(echo "$rsync_output" | grep -oP 'Number of files transferred: \K[0-9,]+' | tr -d ',' || echo "0")
|
||||||
|
fi
|
||||||
|
if [[ -z "$files_transferred" ]]; then
|
||||||
|
files_transferred="0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parsear tamaño total transferido
|
||||||
|
local total_size
|
||||||
|
total_size=$(echo "$rsync_output" | grep -oP 'Total transferred file size: \K[0-9,.]+ \w+' || echo "0 bytes")
|
||||||
|
if [[ -z "$total_size" ]]; then
|
||||||
|
total_size="0 bytes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Emitir JSON a stdout
|
||||||
|
printf '{"files_transferred": %s, "total_size": "%s", "ssh_alias": "%s", "remote_dir": "%s"}\n' \
|
||||||
|
"$files_transferred" \
|
||||||
|
"$total_size" \
|
||||||
|
"$ssh_alias" \
|
||||||
|
"$remote_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
rsync_deploy "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: setup_registry_api
|
||||||
|
kind: pipeline
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json"
|
||||||
|
description: "Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check."
|
||||||
|
tags: [launcher, deploy, docker, traefik, registry, coolify, infra, ssh]
|
||||||
|
uses_functions: [rsync_deploy_bash_infra, ssh_exec_go_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: ssh_host
|
||||||
|
desc: "Alias SSH del VPS destino definido en ~/.ssh/config (default: organic-machine.com)"
|
||||||
|
- name: api_token
|
||||||
|
desc: "Token de autenticación para la registry_api (REGISTRY_API_TOKEN). Se escribe en el .env remoto."
|
||||||
|
- name: basic_auth_user
|
||||||
|
desc: "Usuario para basicAuth de Traefik (default: lucas). Se usa para generar el hash bcrypt con htpasswd."
|
||||||
|
- name: basic_auth_pass
|
||||||
|
desc: "Password para basicAuth de Traefik. Se hashea con bcrypt (htpasswd -nB -C 10) y se escapa a $$ para Traefik."
|
||||||
|
output: "JSON con status (ok|error), url del servicio, http_code del health check, duration_ms, ssh_host y remote_dir. Exit code 1 si algún paso falla."
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/setup_registry_api.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos previos
|
||||||
|
|
||||||
|
- `htpasswd` instalado localmente (`apt install apache2-utils`)
|
||||||
|
- `rsync` instalado localmente
|
||||||
|
- SSH alias `organic-machine.com` (o el host indicado) configurado en `~/.ssh/config`
|
||||||
|
- El usuario SSH debe tener `sudo` sin password para `mkdir -p /data/coolify/proxy/dynamic/` y `tee` en esa ruta
|
||||||
|
- Red Docker `coolify` existente en el VPS (se crea automáticamente si no existe)
|
||||||
|
- Traefik corriendo con file watcher en `/data/coolify/proxy/dynamic/` (Coolify proxy estándar)
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Forma directa como script
|
||||||
|
bash bash/functions/infra/setup_registry_api.sh \
|
||||||
|
organic-machine.com \
|
||||||
|
"mi-token-secreto" \
|
||||||
|
lucas \
|
||||||
|
"mi-password"
|
||||||
|
|
||||||
|
# Como función sourced
|
||||||
|
source bash/functions/infra/setup_registry_api.sh
|
||||||
|
|
||||||
|
result=$(setup_registry_api \
|
||||||
|
"organic-machine.com" \
|
||||||
|
"mi-token-secreto" \
|
||||||
|
"lucas" \
|
||||||
|
"mi-password")
|
||||||
|
echo "$result"
|
||||||
|
# {"status":"ok","url":"https://registry.organic-machine.com/api/status","http_code":"200","duration_ms":45231,"ssh_host":"organic-machine.com","remote_dir":"/opt/fn-registry-build/apps/registry_api"}
|
||||||
|
|
||||||
|
# Via variables de entorno
|
||||||
|
export REGISTRY_API_TOKEN="mi-token-secreto"
|
||||||
|
export BASIC_AUTH_PASS="mi-password"
|
||||||
|
bash bash/functions/infra/setup_registry_api.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pasos del pipeline
|
||||||
|
|
||||||
|
1. **Verificar SSH** — `ssh -o BatchMode=yes -o ConnectTimeout=10` para confirmar acceso al VPS
|
||||||
|
2. **Generar hash bcrypt** — `htpasswd -nB -C 10` localmente, escapar `$` a `$$` para Traefik
|
||||||
|
3. **rsync del repo** — sube el repo completo a `/opt/fn-registry-build/` en el VPS (el Dockerfile necesita el contexto raíz)
|
||||||
|
4. **Subir traefik-dynamic.yml** — reemplaza el placeholder del hash en el template local y lo sube a `/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml` via `sudo tee`
|
||||||
|
5. **Crear .env** — escribe `REGISTRY_API_TOKEN=...` en `apps/registry_api/.env` en el VPS
|
||||||
|
6. **docker compose build && up -d** — construye la imagen (multi-stage, CGO+FTS5) y levanta el container con la red `coolify`
|
||||||
|
7. **Health check** — polling a `https://registry.organic-machine.com/api/status` cada 10s, máximo 12 intentos (2 minutos)
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- El `docker-compose.yml` de la app usa `context: ../../` para incluir `registry/`, `functions/`, `cmd/` y `apps/registry_api/` en el build. Por eso se sincroniza el repo completo y no solo la app.
|
||||||
|
- El Dockerfile genera el binario `registry_api` con `CGO_ENABLED=1 -tags fts5` (SQLite + FTS5). El `registry.db` se genera en el primer arranque via `fn index` dentro del container, o puede montarse externamente via el volumen `/data`.
|
||||||
|
- Traefik detecta el cambio en `/data/coolify/proxy/dynamic/` automáticamente (file provider con file watcher), sin necesidad de reiniciar Traefik.
|
||||||
|
- Para re-deploys: ejecutar el mismo script — rsync es idempotente y `docker compose up -d` recrea el container si la imagen cambió.
|
||||||
|
- Si `REGISTRY_API_TOKEN` está vacío, la API arranca sin autenticación (solo basicAuth de Traefik protege el acceso).
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# setup_registry_api — Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)"
|
||||||
|
|
||||||
|
source "$SCRIPT_DIR/rsync_deploy.sh"
|
||||||
|
|
||||||
|
setup_registry_api() {
|
||||||
|
local ssh_host="${1:-organic-machine.com}"
|
||||||
|
local api_token="${2:-}"
|
||||||
|
local basic_auth_user="${3:-lucas}"
|
||||||
|
local basic_auth_pass="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "$api_token" ]]; then
|
||||||
|
echo "setup_registry_api: REGISTRY_API_TOKEN es obligatorio (parametro 2)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$basic_auth_pass" ]]; then
|
||||||
|
echo "setup_registry_api: basic_auth_pass es obligatorio (parametro 4)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local start_ts
|
||||||
|
start_ts=$(date +%s)
|
||||||
|
|
||||||
|
# 1. Verificar conectividad SSH
|
||||||
|
echo "==> [1/7] Verificando conectividad SSH a '$ssh_host'..." >&2
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$ssh_host" true 2>/dev/null; then
|
||||||
|
echo "setup_registry_api: no se puede conectar a '$ssh_host' via SSH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo " OK: SSH conectado." >&2
|
||||||
|
|
||||||
|
# 2. Generar hash bcrypt para basicAuth de Traefik
|
||||||
|
echo "==> [2/7] Generando hash bcrypt para basicAuth..." >&2
|
||||||
|
if ! command -v htpasswd &>/dev/null; then
|
||||||
|
echo "setup_registry_api: 'htpasswd' no encontrado. Instalar con: apt install apache2-utils" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local traefik_hash
|
||||||
|
traefik_hash=$(htpasswd -nbB "$basic_auth_user" "$basic_auth_pass" 2>/dev/null)
|
||||||
|
if [[ -z "$traefik_hash" ]]; then
|
||||||
|
echo "setup_registry_api: htpasswd no generó un hash válido" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# For Traefik file provider, use single $ (NOT $$ — that's only for Docker labels)
|
||||||
|
echo " OK: hash generado para usuario '$basic_auth_user'." >&2
|
||||||
|
|
||||||
|
# 3. Subir el repo completo al VPS via rsync (el Dockerfile necesita el contexto completo)
|
||||||
|
local remote_build_dir="/opt/fn-registry-build"
|
||||||
|
echo "==> [3/7] Sincronizando repo a '$ssh_host:$remote_build_dir' via rsync..." >&2
|
||||||
|
rsync_deploy "$REGISTRY_ROOT/" "$ssh_host" "$remote_build_dir" >/dev/null || {
|
||||||
|
echo "setup_registry_api: rsync falló" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
echo " OK: repo sincronizado." >&2
|
||||||
|
|
||||||
|
# 4. Subir traefik-dynamic.yml con el hash real a la ruta de Coolify
|
||||||
|
local traefik_dynamic_path="/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml"
|
||||||
|
echo "==> [4/7] Generando y subiendo traefik-dynamic.yml a '$ssh_host:$traefik_dynamic_path'..." >&2
|
||||||
|
|
||||||
|
# Leer el template local y sustituir el placeholder
|
||||||
|
local traefik_template
|
||||||
|
traefik_template=$(< "$REGISTRY_ROOT/apps/registry_api/traefik-dynamic.yml")
|
||||||
|
# Reemplazar la línea del usuario placeholder con el hash real
|
||||||
|
local traefik_rendered
|
||||||
|
traefik_rendered=$(echo "$traefik_template" | sed "s|.*PLACEHOLDER_BASICAUTH_LINE.*| - \"${traefik_hash}\"|g")
|
||||||
|
|
||||||
|
# Crear directorio si no existe y subir
|
||||||
|
ssh "$ssh_host" "sudo mkdir -p /data/coolify/proxy/dynamic/" >&2
|
||||||
|
echo "$traefik_rendered" | ssh "$ssh_host" \
|
||||||
|
"sudo tee '$traefik_dynamic_path' > /dev/null"
|
||||||
|
echo " OK: traefik-dynamic.yml desplegado en '$traefik_dynamic_path'." >&2
|
||||||
|
|
||||||
|
# 5. Crear .env en el VPS con el token de la API
|
||||||
|
local remote_app_dir="$remote_build_dir/apps/registry_api"
|
||||||
|
echo "==> [5/7] Creando .env en '$ssh_host:$remote_app_dir'..." >&2
|
||||||
|
ssh "$ssh_host" "cat > '$remote_app_dir/.env'" <<EOF
|
||||||
|
REGISTRY_API_TOKEN=${api_token}
|
||||||
|
EOF
|
||||||
|
echo " OK: .env creado." >&2
|
||||||
|
|
||||||
|
# 6. Verificar que la red coolify existe; si no, crearla
|
||||||
|
echo "==> [6/7] Verificando red Docker 'coolify' y levantando el stack..." >&2
|
||||||
|
ssh "$ssh_host" bash <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
if ! docker network ls --format '{{.Name}}' | grep -q '^coolify$'; then
|
||||||
|
echo " Creando red Docker 'coolify'..."
|
||||||
|
docker network create coolify
|
||||||
|
fi
|
||||||
|
echo " Red 'coolify' disponible."
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
# docker compose build && up desde el directorio de la app (contexto es ../../ = remote_build_dir)
|
||||||
|
ssh "$ssh_host" bash <<REMOTE
|
||||||
|
set -euo pipefail
|
||||||
|
cd '$remote_app_dir'
|
||||||
|
echo " docker compose build..."
|
||||||
|
docker compose build
|
||||||
|
echo " docker compose up -d..."
|
||||||
|
docker compose up -d
|
||||||
|
echo " Contenedor levantado."
|
||||||
|
REMOTE
|
||||||
|
echo " OK: stack Docker levantado." >&2
|
||||||
|
|
||||||
|
# 7. Health check
|
||||||
|
local health_url="https://registry.organic-machine.com/api/status"
|
||||||
|
echo "==> [7/7] Esperando health check en '$health_url'..." >&2
|
||||||
|
local attempts=0
|
||||||
|
local max_attempts=12
|
||||||
|
local status_code=""
|
||||||
|
while [[ $attempts -lt $max_attempts ]]; do
|
||||||
|
status_code=$(curl -sk -o /dev/null -w "%{http_code}" \
|
||||||
|
-u "${basic_auth_user}:${basic_auth_pass}" \
|
||||||
|
"$health_url" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$status_code" == "200" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
echo " Intento $attempts/$max_attempts — HTTP $status_code, esperando 10s..." >&2
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
|
local end_ts
|
||||||
|
end_ts=$(date +%s)
|
||||||
|
local duration_ms=$(( (end_ts - start_ts) * 1000 ))
|
||||||
|
|
||||||
|
if [[ "$status_code" != "200" ]]; then
|
||||||
|
printf '{"status":"error","url":"%s","http_code":"%s","duration_ms":%d,"msg":"health check timeout tras %d intentos"}\n' \
|
||||||
|
"$health_url" "$status_code" "$duration_ms" "$max_attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " OK: servicio respondiendo HTTP 200." >&2
|
||||||
|
printf '{"status":"ok","url":"%s","http_code":"%s","duration_ms":%d,"ssh_host":"%s","remote_dir":"%s"}\n' \
|
||||||
|
"$health_url" "$status_code" "$duration_ms" "$ssh_host" "$remote_app_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
# Uso: setup_registry_api.sh [ssh_host] [api_token] [basic_auth_user] [basic_auth_pass]
|
||||||
|
# Variables de entorno alternativas: SSH_HOST, REGISTRY_API_TOKEN, BASIC_AUTH_USER, BASIC_AUTH_PASS
|
||||||
|
setup_registry_api \
|
||||||
|
"${1:-${SSH_HOST:-organic-machine.com}}" \
|
||||||
|
"${2:-${REGISTRY_API_TOKEN:-}}" \
|
||||||
|
"${3:-${BASIC_AUTH_USER:-lucas}}" \
|
||||||
|
"${4:-${BASIC_AUTH_PASS:-}}"
|
||||||
|
fi
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user