143 Commits

Author SHA1 Message Date
egutierrez d771c21a46 docs: actualizar README de issues — marcar 8 issues como completados (wave 1-3)
Issues integrados a master en esta serie:
- 0010 Auth System (JWT, passwords, OAuth2, RBAC, sessions)
- 0011 WebSocket & SSE Server
- 0014 File Upload & Storage (+ S3 stubs)
- 0016 Rate Limiting (token bucket)
- 0019 Structured Logging (slog-based)
- 0021 CRUD Generator generico
- 0022 Init Pipelines (scaffolding api/web/desktop/cli)
- 0024 Dashboard YAML split por tab

Total aprox: 54 funciones y 23 tipos nuevos en functions/infra/
+ 4 pipelines bash en bash/functions/pipelines/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:00:57 +02:00
egutierrez 4aa3bc2d94 chore: regenerar registry.db tras integración 0022 2026-04-18 18:00:17 +02:00
egutierrez 7bda65209c merge: issue/0022-init-pipelines — scaffolding pipelines bash (api, web, desktop, cli) 2026-04-18 18:00:10 +02:00
egutierrez c25f623355 docs: cerrar issue 0022
Los 4 init pipelines (init_api_app, init_web_app, init_desktop_app,
init_cli_app) estan implementados, verificados end-to-end con `fn run`, e
indexados en registry.db como kind=pipeline + tag=launcher. Guia consolidada
en docs/init-pipelines.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:59:04 +02:00
egutierrez 3b37827d16 docs: guia consolidada docs/init-pipelines.md + regenerar registry.db
docs/init-pipelines.md: referencia rapida de los 4 init pipelines con tabla
resumen, arbol de decision, combinaciones comunes, FAQ y estructura
generada por tipo.

registry.db: re-indexado para registrar los 4 nuevos pipelines como
kind=pipeline, purity=impure, domain=pipelines, tag=launcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:58:53 +02:00
egutierrez bcfe87af7f feat: init_cli_app bash pipeline — scaffold Go CLI app con TUI opcional
Genera apps/{nombre}/ con main.go (subcommand routing via os.Args + switch),
cmd_version.go, cmd_status.go, Makefile (build/run/install/test/vet/clean),
.gitignore, go.mod y app.md. Sin cobra/urfave — consistente con el resto de
apps del registry.

Flag --with-tui anade model.go con un modelo Bubbletea fullscreen (lista
filtrable con bubbles/list, spinner con bubbles/spinner, dark theme con
lipgloss). main.go arranca la TUI con tea.NewProgram(m, WithAltScreen) si no
hay args; sino hace subcommand routing normal.

Verifica con go mod tidy + go vet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:56:55 +02:00
egutierrez 526d7f4977 feat: init_desktop_app bash pipeline — scaffold Wails desktop app
Genera apps/{nombre}/ con Wails v2 backend (main.go con embed frontend/dist,
app.go con struct App y bindings Greet/GetVersion) + frontend Vite+React+Mantine
con alias @fn_library.

Flag --with-db anade store.go con SQLite (schema items) y bindings CRUD
(ListItems, CreateItem); app.go se regenera con campo db.

wails.json con scripts pnpm, go.mod con replace a fn-registry, app.md con
framework wails+vite+react+mantine y dir_path correcto.

Verifica con go mod tidy al final. wails build requiere CLI instalado pero
el scaffold funciona sin el.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:54:55 +02:00
egutierrez 6d63f058de feat: init_web_app bash pipeline — scaffold Go API + React frontend
Extiende init_api_app anadiendo la capa frontend: pnpm + vite + react +
@mantine/core. Genera frontend/ con vite.config.ts (proxy /api y /health al
backend + alias @fn_library a frontend/functions/ui), src/main.tsx con
MantineProvider, src/App.tsx con AppShell y src/pages/Home.tsx consumiendo
/api/v1/status.

Flags: --port N, --with-auth, --with-db (delegadas a init_api_app).

Docker compose para desarrollo. Verifica con pnpm install && pnpm build si
pnpm esta disponible (skippable con SKIP_PNPM_BUILD=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:52:34 +02:00
egutierrez e19bc09f4d feat: init_api_app bash pipeline — scaffold Go HTTP API app
Genera apps/{nombre}/ con main.go (http_serve + router + middleware chain +
graceful shutdown), handlers.go (HTTPJSONResponse), config.go (env vars),
migrations/001_initial.sql, Makefile, .env.example, .gitignore, go.mod y
app.md con frontmatter correcto.

Flags opcionales:
  --port N      puerto default del server (default 8080)
  --with-auth   jwt_middleware + login/register + tabla users/sessions
  --with-db     store.go con helpers CRUD y setup SQLite
  --with-ops    stub para fn ops init

Compone 8+ funciones del registry (http_*, migration_up, password_*, jwt_*).
Verifica con go vet al final.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:35 +02:00
egutierrez 7eab4a52e9 chore: regenerar registry.db tras integración 0010 2026-04-18 17:45:44 +02:00
egutierrez 057f55c8a9 merge: issue/0010-auth-system — JWT, passwords, OAuth2, RBAC, sessions (13 fns, 6 tipos) 2026-04-18 17:45:30 +02:00
egutierrez 7b2004c649 docs: cerrar issue 0010 2026-04-18 17:44:41 +02:00
egutierrez 6c83263d9b chore: regenerar registry.db con auth system (13 funciones, 6 tipos nuevos) 2026-04-18 17:44:40 +02:00
egutierrez f46fde3656 feat: rbac_check (pure), jwt_middleware, rbac_middleware
Fase 5 del issue 0010 — RBAC y middlewares de auth.
- rbac_check es pura: solo recorre la matriz roles/permisos
- jwt_middleware extrae token del header Authorization: Bearer, valida e
  inyecta claims en el context con una key privada (jwtCtxKey struct{})
- rbac_middleware requiere jwt_middleware antes; lee role de claims.Custom
- Helper JWTClaimsFromContext para acceder a las claims desde handlers
- 401 claro si RBAC se usa sin JWT antes (code: no_claims)
2026-04-18 17:44:04 +02:00
egutierrez 4601af88b5 feat: oauth2_auth_url (pure), oauth2_exchange, oauth2_refresh
Fase 4 del issue 0010 — cliente OAuth2 sin golang.org/x/oauth2.
- Oauth2AuthURL es pura: solo construye la URL con net/url
- Oauth2Exchange/Refresh hacen POST application/x-www-form-urlencoded
- ExpiresAt calculado como now + expires_in del proveedor
- Refresh conserva el token original si el proveedor no devuelve uno nuevo
- Tests con httptest.NewServer como mock del proveedor
2026-04-18 17:41:42 +02:00
egutierrez fc1ebb4967 feat: session_create, session_validate, session_cleanup
Fase 3 del issue 0010 — sesiones SQLite como alternativa a JWT.
- Tabla sessions creada con CREATE TABLE IF NOT EXISTS (autosetup)
- Tokens de 32 bytes aleatorios (crypto/rand) codificados en hex (256 bits)
- Indices en user_id y expires_at
- Prepared statements para evitar SQL injection
- SessionCleanup para eliminar expiradas periodicamente
2026-04-18 17:40:13 +02:00
egutierrez 07341aa89f feat: jwt_generate, jwt_validate, password_hash, password_verify
Fase 2 del issue 0010 — auth core:
- jwt_generate/validate: HS256 manual con crypto/hmac + crypto/sha256
- password_hash/verify: wrappers de golang.org/x/crypto/bcrypt (cost 12 default)
- JWT rechaza alg != HS256 para mitigar ataque 'alg=none'
- hmac.Equal para comparacion constant-time de firmas
2026-04-18 17:39:00 +02:00
egutierrez 4bc6d1bced feat: tipos auth (JWTClaims, Session, OAuthConfig, OAuthTokens, Permission, Role)
Fase 1 del issue 0010 — tipos base del sistema de auth en dominio infra.
Define las estructuras que usaran jwt_*, session_*, oauth2_* y rbac_*.

Añade dep golang.org/x/crypto/bcrypt para el hashing de passwords.
2026-04-18 17:37:19 +02:00
egutierrez 5f282bedc5 chore: regenerar registry.db tras integración wave 1 (0011, 0014, 0016, 0019, 0021, 0024) 2026-04-18 17:33:44 +02:00
egutierrez 3b2cd26a06 merge: issue/0011-websocket-sse — WebSocket + SSE (8 fns, 4 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:32 +02:00
egutierrez 66e54f092d merge: issue/0014-file-upload — file upload/storage + S3 stubs (11 fns, 3 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:28 +02:00
egutierrez 22994f14bf merge: issue/0021-crud-generator — CRUD generator genérico (9 fns, 4 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:23 +02:00
egutierrez e96f8eaf6a merge: issue/0019-structured-logging — slog-based structured logging (7 fns, 3 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:18 +02:00
egutierrez 5bbdf2ff16 merge: issue/0016-rate-limiting — token bucket rate limiter (6 fns, 3 tipos) 2026-04-18 17:33:07 +02:00
egutierrez 19722cb085 merge: issue/0024-dashboard-yaml-split-por-tab — cerrar issue (código en repo dataforge/auto_metabase) 2026-04-18 17:33:03 +02:00
egutierrez 6fac9e1ef0 chore: gitignorear external/ y worktrees/
external/ contiene symlink a repo_Claude (skills).
worktrees/ es el directorio que usa parallel-fix-issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:32:54 +02:00
egutierrez 1ab39d105a docs: cerrar issue 0011 2026-04-18 17:30:28 +02:00
egutierrez 2c1a956b32 chore: regenerar registry.db con WS/SSE (issue 0011) 2026-04-18 17:30:24 +02:00
egutierrez e35ec39c10 feat: WebSocket upgrader, hub, send, broadcast, handler con tests (issue 0011 fase 3-4) 2026-04-18 17:29:37 +02:00
egutierrez 637bc8fd34 feat: SSE handler, send y keepalive (issue 0011 fase 2) 2026-04-18 17:25:03 +02:00
egutierrez 75157f528a docs: cerrar issue 0021 2026-04-18 17:18:11 +02:00
egutierrez 77be3ce325 chore: regenerar registry.db con funciones y tipos CRUD
Indexa las 9 funciones crud_* y los 4 tipos CRUDResource/CRUDField/
CRUDListParams/CRUDListResult del issue 0021.
2026-04-18 17:18:04 +02:00
egutierrez 9634cfdb4a docs: cerrar issue 0014 2026-04-18 17:17:49 +02:00
egutierrez 6cf006d87b chore: regenerar registry.db con file upload + s3 stubs 2026-04-18 17:17:46 +02:00
egutierrez 4d25ebd070 test(crud): cobertura completa de los handlers y generadores CRUD
Anade tests unitarios e integracion sobre SQLite in-memory:
- CRUDDefineResource: casos felices y rechazo de inputs invalidos
  (nombre vacio, tipos no soportados, nombres reservados, duplicados).
- CRUDGenerateTableSQL: columnas base, NOT NULL/UNIQUE/DEFAULT, deleted_at
  con soft_delete y verificacion de que el DDL es ejecutable en sqlite.
- Create + Get: creacion feliz, validaciones required/min_length/max_length/
  enum/min/max, 409 en UNIQUE, GET 200/404.
- List: paginacion, filtros, orden ascendente, campos desconocidos ignorados.
- Update: partial update, 404 y validacion de campos enviados.
- Delete: hard delete, soft delete, 404, ocultar soft-deleted en list.
- Integracion end-to-end con httptest.NewServer cubriendo CRUD completo y
  multiples recursos registrados en el mismo mux.
2026-04-18 17:17:42 +02:00
egutierrez 0bd91f04b8 feat: stubs s3_upload, s3_download, s3_presign_url (issue 0014 fase 4) 2026-04-18 17:17:19 +02:00
egutierrez 0bfe267501 docs: cerrar issue 0019 structured logging
Funciones, tipos y tests implementados. Registry actualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:45 +02:00
egutierrez 4b420fb24b feat: file_save_disk, file_delete, file_serve, upload_parse, upload_handler, thumbnail_generate (issue 0014 fase 3) 2026-04-18 17:15:39 +02:00
egutierrez 3262d058a6 chore: regenerar registry.db con funciones y tipos de structured logging
fn index registra:
- 7 funciones: logger_new, logger_with, log_debug, log_info, log_warn, log_error, logger_middleware
- 3 tipos: Logger, LogLevel, LogEntry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:37 +02:00
egutierrez 69dcfec4eb feat(crud): handlers HTTP y registro de rutas para recursos CRUD
Anade los 5 handlers CRUD genericos (list, get, create, update, delete) a
partir de un CRUDResource y *sql.DB, la factory crud_generate_handlers que
compone los 5 en un mapa, y crud_register_routes que registra todas las
rutas REST en un http.ServeMux con la sintaxis METHOD /path de Go 1.22+.

Caracteristicas:
- List con paginacion (page, per_page), orden (sort_by, sort_dir) y
  filtros exactos (filter_<campo>), validando nombres de columna contra
  la definicion del recurso para evitar SQL injection.
- Create valida required y validaciones (min/max, min_length/max_length,
  pattern, enum) antes de insertar; mapea UNIQUE violations a 409.
- Update hace partial update — solo los campos presentes en el JSON.
- Delete hace hard delete o soft delete segun CRUDResource.SoftDelete.
- UUIDs generados via github.com/google/uuid; timestamps en RFC3339Nano UTC.

Los handlers usan las funciones HTTP del registry (http_json_response,
http_error_response, http_parse_body) y se pueden componer con el mux
via http_router.
2026-04-18 17:15:33 +02:00
egutierrez 31708d0942 feat(crud): tipos y generador de DDL para recursos CRUD
Anade los tipos CRUDResource, CRUDField, CRUDListParams y CRUDListResult
que modelan un recurso CRUD sobre SQLite, junto con dos funciones puras:
- crud_define_resource valida nombre, tabla y campos (tipos SQLite validos,
  nombres reservados, duplicados) antes de retornar el CRUDResource.
- crud_generate_table_sql genera el DDL CREATE TABLE IF NOT EXISTS con
  id TEXT PRIMARY KEY, timestamps estandar y, si aplica, deleted_at para
  soft delete.

Primera capa de 0021 — el resto (handlers + registro de rutas) se apoya
sobre estas estructuras.
2026-04-18 17:15:21 +02:00
egutierrez 53976c0c31 docs: cerrar issue 0016 (rate limiting) 2026-04-18 17:14:50 +02:00
egutierrez 04c3ead5fa chore: regenerar registry.db con funciones y tipos de rate limiting 2026-04-18 17:14:49 +02:00
egutierrez e076901aa9 test: cobertura de structured logging (logger_new, logger_with, log_*, logger_middleware)
- LoggerNew: formatos validos e invalidos, output nil, filtrado por nivel
- LoggerWith: anadir fields, no mutacion del base, apilamiento, nil-safe
- LogDebug/Info/Warn/Error: niveles correctos en JSON, campos variadicos, logger nil no panic
- LoggerMiddleware: method/path/status/duration_ms, default 200, preserva campos del logger

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:41 +02:00
egutierrez d80f0412a8 feat: logger_middleware — middleware HTTP con logs estructurados (infra)
Middleware que envuelve cualquier http.Handler y emite un log info por
cada request con method, path, status y duration_ms. Hereda los campos
contextuales del Logger (app, version, request_id...) y se compone con
HTTPMiddlewareChain + HTTPCORSMiddleware.

Diferencia con http_logger_middleware: este escribe JSON estructurado via
slog en vez de texto plano a un io.Writer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:36 +02:00
egutierrez 9e8c0d66bb feat: log_debug, log_info, log_warn, log_error (infra)
Funciones de nivel que delegan al *slog.Logger interno del Logger.
Todas son impuras y soportan logger nil sin panic (no-op).
Los fields se pasan como pares key-value variadicos estilo slog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:29 +02:00
egutierrez df0227d4f2 feat(infra): rate limit middlewares HTTP por IP y por key + tests
Implementa fase 2 del issue 0016:
- rate_limit_middleware: limita por IP (X-Forwarded-For > X-Real-IP > RemoteAddr)
- rate_limiter_by_key: middleware configurable con keyFunc custom (API key, user ID...)
- Cuando se rechaza responde 429 con HTTPError JSON y headers Retry-After + X-RateLimit-*
- Tests con httptest.NewRecorder cubriendo: limite, burst, IPs independientes, XFF prioritario,
  recarga temporal, JSON body, headers IETF, GC stop idempotente, key vacia salta limit
2026-04-18 17:14:25 +02:00
egutierrez ae22787e60 feat: logger_new y logger_with sobre log/slog (infra)
- logger_new (impuro): construye *Logger con handler JSON/text segun formato
- logger_with (puro): clona el Logger anadiendo campos contextuales via slog.With

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:23 +02:00
egutierrez ab3069ae17 feat: tipos Logger, LogLevel y LogEntry para structured logging (infra)
Tipos base para las funciones de structured logging sobre log/slog:
- LogLevel: suma enum Debug/Info/Warn/Error
- Logger: wrapper producto con nivel, output, formato y fields contextuales
- LogEntry: modelo canonico JSON para tests y pipelines de logs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:19 +02:00
egutierrez 1675d2bb84 feat: file_validate_type y file_unique_name puras (issue 0014 fase 2) 2026-04-18 17:12:05 +02:00
egutierrez 4ac93a0933 feat(infra): rate limiter token-bucket in-memory + tipos y core funcs
Implementa fase 1 del issue 0016:
- Tipos RateLimiter, RateLimitConfig y RateLimitResult en types/infra/
- rate_limiter_new: constructor con validacion rate/burst > 0
- rate_limiter_check: evalua token bucket por key, calcula Allowed/Remaining/ResetAt/RetryAfter
- rate_limit_headers (pure): construye headers IETF X-RateLimit-* y Retry-After
- rate_limiter_cleanup: goroutine GC de entries inactivas con stop idempotente

Sin dependencias externas (no Redis). sync.Mutex + map. Algoritmo token bucket
estandar con recarga lineal proporcional al tiempo transcurrido.
2026-04-18 17:11:22 +02:00
egutierrez ae0c4b7389 docs: cerrar issue 0024
El split de dashboards YAML por tab ya esta implementado y committeado en
apps/auto_metabase (repo dataforge/auto_metabase, commit 47b5f89):

- dashboard_split.py: helpers puros split_dashboard_payload() y merge_dashboard_parts()
- dashboard_split_test.py: 23 tests passing (round-trip, edge cases, real aurgi YAML)
- sync_pull.py: escribe directorio {slug}/_dashboard.yaml + tab_*.yaml
- sync_push.py: lee directorio o legacy monolitico, reconstruye payload unificado
- sync_validate.py: valida parent_slug, tab_ids y detecta archivos huerfanos
- payload.py: resolve dashboards/{slug}/_dashboard.yaml preferentemente
- app.md: documenta nuevo layout con seccion "Dashboard split por tab"
- Backward-compat con legacy dashboards/{slug}.yaml
- Aplicado a aurgi (dashboard id=734) con 9 tabs separados

Las apps viven en repos Gitea independientes (dataforge/auto_metabase), por
lo que los cambios de codigo no quedan reflejados en el historial de
fn_registry, solo el cierre del issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:10:36 +02:00
egutierrez 3d47e74ec7 feat: tipos UploadedFile, StorageConfig, S3Config en infra (issue 0014 fase 1) 2026-04-18 17:10:31 +02:00
egutierrez 0255207514 feat: tipos WSHub, WSClient, WSMessage, SSEEvent (issue 0011 fase 1) 2026-04-18 17:10:28 +02:00
egutierrez 95826cb14f merge: quick/init-jupyter-project-support — init_jupyter_analysis v1.1.0 con soporte --project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:40:13 +02:00
egutierrez ad8ce45865 chore: regenerar registry.db con write_analysis_md_bash_infra
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:39:54 +02:00
egutierrez fee892f38e feat: init_jupyter_analysis v1.1.0 — soporte --project, --desc, --tags
Nueva funcion write_analysis_md_bash_infra genera analysis.md con frontmatter.
El pipeline ahora acepta --project para crear analisis directamente en
projects/{proyecto}/analysis/{nombre}/, valida que el proyecto exista,
genera analysis.md con dir_path correcto y ejecuta fn index al final.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 15:39:20 +02:00
egutierrez dcefa13d2d merge: quick/temp-workspace — documentar workspace efimero temp/ 2026-04-16 23:42:06 +02:00
egutierrez f8aa5e8072 chore: regenerar registry.db tras fn sync
Actualiza registry.db con los datos sincronizados desde el servidor
registry_api (https://registry.organic-machine.com).

Sync realizado desde home-wsl: 44 items enviados, 20 actualizados en
el servidor, 44 recibidos, 18 locations importadas localmente.

Impacto: la BD queda alineada con el estado compartido entre PCs.
No toca schema ni codigo fuente; solo es un snapshot binario.
2026-04-16 23:41:52 +02:00
egutierrez bb15b142bf docs: añadir workspace efimero temp/
Documenta la carpeta temp/ como workspace desechable para pruebas rapidas
(APIs, scripts exploratorios, prototipos) y la añade a .gitignore.

Cambios:
- .claude/CLAUDE.md: incluye temp/ en el arbol de estructura del proyecto
  con la nota de que es efimero, gitignored y no indexado.
- .claude/rules/apps_vs_functions.md: nueva seccion "temp/ — workspace
  efimero" con las reglas de uso (no es codigo del registry, estructura
  libre, se extrae al registry si algo resulta util, se puede borrar).
- .gitignore: añade temp/ para que su contenido nunca se versione.

Impacto: los agentes y el humano tienen ahora un lugar claro donde
probar cosas sin contaminar el registry ni preocuparse por limpieza.
No toca codigo existente ni la estructura de apps/ o functions/.
2026-04-16 23:41:47 +02:00
egutierrez 28364cf212 feat: registry_api + fn sync — sincronización de registry.db entre PCs
Nuevo sistema para mantener datos no regenerables (proposals, apps, projects,
analysis, vaults, pc_locations) sincronizados entre múltiples máquinas via
una API HTTP central desplegada en organic-machine.com.

- Migración 011: tabla pc_locations (mapa de ubicaciones por PC)
- registry/models.go: struct PcLocation
- registry/store.go: CRUD PcLocation + helpers de sync
- cmd/fn/sync.go: subcomando fn sync (push+pull, status, locations)
- bash/functions/infra/setup_registry_api: pipeline de deploy Docker+Traefik
- CLAUDE.md: documentación de sync y pc_locations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:12:38 +02:00
egutierrez 295ab491a3 merge: quick/metabase-expansion-v2 — expansion Metabase: snippets, notifications, filters, export, ProseMirror 2026-04-14 19:03:56 +02:00
egutierrez debbdb86be chore: actualizar exports __init__.py y regenerar registry.db
Nuevos exports: snippets, notifications, dashboard_filters, export_card,
create_model, prosemirror_card_embed. registry.db regenerado con todas las
funciones nuevas indexadas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:27 +02:00
egutierrez 58539f45c9 feat(metabase): expansion cards y documents — export, model, ProseMirror validation, copy nativo
Cards: export_card (CSV/XLSX/JSON), create_model (type=model para fuentes MBQL).
Documents: prosemirror_card_embed helper (resizeNode envolviendo cardEmbed),
validacion automatica contra whitelist TipTap antes de enviar, copy_document
refactorizado al endpoint nativo POST /api/document/:id/copy.
Docs: dataset_query legacy vs MBQL5, template-tags, whitelist de nodos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:19 +02:00
egutierrez 4299482b75 feat(metabase): nuevos modulos — snippets, notifications, dashboard_filters
Tres modulos nuevos con funciones CRUD completas:
- snippets: list, get, create, update, archive (SQL reutilizable)
- notifications: list, create_card_alert, create_dashboard_subscription, update, delete
- dashboard_filters: add_dashboard_filter (parameter_mappings sobre cards existentes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:03:08 +02:00
egutierrez 7081c3b4d1 merge: quick/metabase-expansion-and-issues — expansion Metabase + issues tracker 2026-04-13 23:32:22 +02:00
egutierrez cb25bf6d1b chore: regenerar registry.db con las nuevas funciones Metabase
Resultado de `fn index` tras la expansion del paquete metabase. Indexa las nuevas funciones y tipos para que queden buscables via FTS5.
2026-04-13 23:31:51 +02:00
egutierrez e5c17f89d7 docs(issues): nuevos issues de backlog y limpieza de tracker
- Añade issues nuevos al backlog: 0010 auth-system, 0011 websocket-sse, 0014 file-upload, 0016 rate-limiting, 0019 structured-logging, 0021 crud-generator, 0022 init-pipelines, 0024 dashboard-yaml-split-por-tab.
- Elimina 0008-sqlite-api-web.md (ya no aplica).
- Actualiza dev/issues/README.md con el estado del tracker.
2026-04-13 23:31:47 +02:00
egutierrez 9a28d08e38 feat(metabase): expansion de funciones Python — documents, collections, permissions, validation
Añade un conjunto amplio de funciones al paquete python/functions/metabase:
- Nuevos modulos: collections.py, documents.py, maintenance.py, permissions.py, validation.py (+ test).
- Ampliacion de cards.py, dashboards.py, client.py e __init__.py para exponer las nuevas operaciones.
- Funciones de documentos (create/get/update/delete/archive/copy/move + comentarios), grupos y memberships, permission/collection graphs, copy/move de cards y dashboards, validacion de MBQL/SQL y payloads, actualizacion segura de dashboards y fix_null_ratio.
- .md por funcion con frontmatter para que fn index los registre.
- Actualiza pyproject.toml y uv.lock con las dependencias resultantes.

Impacto: ampliamente mas cobertura de la API de Metabase desde el registry, reutilizable por apps y analisis. No toca Go ni frontend.
2026-04-13 23:31:42 +02:00
egutierrez baa72e211e chore: untrack apps/auto_metabase (lives in its own repo dataforge/auto_metabase) 2026-04-13 14:28:26 +02:00
egutierrez 58fab5ad34 feat(auto_metabase): push-all + describe/sql + auto-inject de dashcards
- push_all(): pushea todos los YAMLs de un proyecto (cards primero,
  dashboards despues), solo CREATE/UPDATE, resiliente a fallos por item
- explore.py: comandos describe (schema de DB) y sql (query ad-hoc con
  limite, cap 5MB, bloqueo de escrituras destructivas)
- payload.py: auto-inyecta id:-N, visualization_settings:{} y
  parameter_mappings:[] en dashcards nuevas para evitar 500 en push
- test_local: 11 cards + 3 dashboards sobre Sample Database de Metabase
- registry.db regenerado con auto_metabase_py_analytics indexada

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:14:05 +02:00
egutierrez 854f42ed6b docs: actualizar README de issues — marcar 8 issues de Wave 1 como completados
Wave 1 completada e integrada a master:
- 0009 HTTP Server Foundation
- 0012 Email & SMTP
- 0013 Background Job Queue
- 0015 Database Migrations
- 0017 Frontend Data Hooks
- 0018 Config & Env Management
- 0020 PDF Generation
- 0023 Testing Utilities

Pendientes (Wave 2 descartada, Wave 3 sin iniciar):
0010, 0011, 0014, 0016, 0019, 0021, 0022

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:26:05 +02:00
egutierrez 1f59b5b4c3 fix: rename openTestDB to openMigrationTestDB to avoid redeclaration with job_queue_test.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:27 +02:00
egutierrez e74ed2e7d3 merge: issue/0023-testing-utils — Go testing utilities
# Conflicts:
#	registry.db
2026-04-13 02:06:03 +02:00
egutierrez 93ae1bd497 merge: issue/0020-pdf-generation — PDF generation Python+Go
# Conflicts:
#	registry.db
2026-04-13 02:05:55 +02:00
egutierrez b0038aab43 merge: issue/0018-config-env — Config & env management
# Conflicts:
#	registry.db
2026-04-13 02:05:47 +02:00
egutierrez 3bb0c7c6f2 merge: issue/0017-frontend-hooks — React HTTP hooks
# Conflicts:
#	registry.db
2026-04-13 02:05:39 +02:00
egutierrez fb9a598aa9 merge: issue/0015-db-migrations — SQL migration system
# Conflicts:
#	registry.db
2026-04-13 02:05:31 +02:00
egutierrez aed8d5b308 merge: issue/0013-background-jobs — SQLite job queue
# Conflicts:
#	registry.db
2026-04-13 02:05:20 +02:00
egutierrez 6aacdb0323 merge: issue/0012-email-smtp — Email SMTP functions 2026-04-13 02:05:03 +02:00
egutierrez 116bbb5e87 merge: issue/0009-http-server — implementación paralela 2026-04-13 02:04:50 +02:00
egutierrez 2fd6eeb95b docs: cerrar issue 0012 — email SMTP implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:04:11 +02:00
egutierrez cdad1b5832 chore: reindexar registry con funciones email SMTP
683 funciones, 110 tipos, 800 unit_tests indexados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:34 +02:00
egutierrez 9747069182 docs: cerrar issue 0020 — PDF Generation implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:28 +02:00
egutierrez a97dd9d9f5 docs: cerrar issue 0015 — sistema de migraciones SQL implementado
6 funciones (migration_parse, migration_validate, migration_create, migration_up,
migration_down, migration_status) + 2 tipos (Migration, MigrationStatus) indexados
en registry.db. 26 tests, todos pasan. Build limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:19 +02:00
egutierrez 38ac24a0ed chore: añadir fpdf2 y pypdf a python/pyproject.toml y uv.lock
Dependencias necesarias para pdf_create_py_infra, pdf_add_table_py_infra
y pdf_merge_py_infra. Instaladas previamente via uv add en el repo principal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:16 +02:00
egutierrez 851732ce7d docs: cerrar issue 0017 — frontend hooks implementados 2026-04-13 02:03:15 +02:00
egutierrez ff7da29638 feat: funciones email SMTP en Python (infra)
smtp_send: conecta+envia+cierra en un paso via smtplib (TLS/STARTTLS/plain).
email_build_html: construye EmailMessagePy frozen dataclass con cuerpo HTML.
Solo stdlib Python: smtplib, email.mime. Tests con mock SMTP server threading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:12 +02:00
egutierrez 94be3b62e7 feat: agregar hooks React HTTP genericos — issue 0017
4 tipos en frontend/types/core/: FetchState, MutationState, FormState, APIClientConfig.
9 funciones en frontend/functions/core/: api_client, http_cache, use_fetch,
use_mutation, use_infinite_scroll, use_form, use_debounced_search, use_sse, use_websocket.
Zero dependencias externas — solo React + fetch nativo. Cache in-memory con SWR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:03:10 +02:00
egutierrez df424f2de0 feat: issue/0020 — generacion de PDFs en Python y Go
Añade 3 tipos Python (PDFDoc, PDFPage, PDFStyle) y 10 funciones Python
para construir PDFs con fpdf2 (builder fluent), fusionar PDFs con pypdf
y convertir HTML/Markdown a PDF via weasyprint (stub si no disponible).
Añade pdf_simple_report en Go como stub hasta que go-pdf/fpdf se integre.

- python/types/infra/: pdf_doc, pdf_page, pdf_style
- python/functions/infra/: pdf_create, pdf_add_page, pdf_add_text,
  pdf_add_table, pdf_add_image, pdf_add_header_footer, pdf_from_html,
  pdf_from_markdown, pdf_merge, pdf_save
- functions/infra/pdf_simple_report.go: stub Go con ReportSection/ReportTable
- 17 tests Python pasando (pytest)
- fpdf2 y pypdf añadidos via uv al venv Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:51 +02:00
egutierrez 7670b671f2 docs: cerrar issue 0018 — Config & Env Management implementado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:28 +02:00
egutierrez 4ef5c6e5b8 fix: corregir IDs de tipos en uses_types/returns a formato PascalCase
Los IDs de tipos Go usan PascalCase: Migration_go_infra, MigrationStatus_go_infra.
Actualizar los .md de todas las funciones migration para referenciar los IDs correctos.
Re-indexar: 681 funciones, 109 tipos, 0 errores de validacion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:02:27 +02:00
egutierrez af9ad48c9b feat(infra): funciones Python config_from_env y dotenv_load con tests
- config_from_env_py_infra: dataclass + field metadata, tipos str/int/float/bool/list
- dotenv_load_py_infra: parser .env con semantica de no-sobreescritura
- 15 tests unitarios Python, todos PASS
- registry.db actualizado con fn index (685 funciones, 109 tipos)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:56 +02:00
egutierrez 327937124f feat(infra): funciones impuras Go para carga de env/config (dotenv, env_require, config_from_env, config_from_file)
- dotenv_load: parser .env con no-sobreescritura y soporte de comillas
- env_require: os.Getenv con error descriptivo fail-fast
- env_require_all: verifica multiples vars y lista todas las faltantes
- config_from_env: reflection sobre struct tags env/default/required/secret, 5 tipos soportados
- config_from_file: JSON via stdlib, YAML stub con not-implemented
- 25 tests unitarios, todos PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:50 +02:00
egutierrez 3d515aa441 feat(infra): tipos ConfigError y ConfigValidation + funciones puras Go (validate, merge, dump)
- ConfigError y ConfigValidation como tipos producto con sus .md en types/infra/
- config_validate: validacion con tags required/format/min/max/oneof via reflection
- config_merge: merge no-mutante de map[string]string con precedencia de override
- config_dump: serializacion de structs a map con mascara *** para campos secret
- 17 tests unitarios, todos PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:43 +02:00
egutierrez 9b0e1f836d feat: funciones impuras migration_create, migration_up, migration_down, migration_status
Fase 2 del issue 0015. MigrationCreate (crea archivo .sql template con version
auto-calculada), MigrationUp (aplica migraciones pendientes en transacciones
individuales), MigrationDown (revierte ultimas N via down_sql de _migrations),
MigrationGetStatus (cruza disco con BD, detecta orphaned). Tests de integracion:
ciclo completo create->up->status->down->status. 26 tests, todos pasan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:40 +02:00
egutierrez ca15655268 feat: tipos Migration/MigrationStatus y funciones puras migration_parse + migration_validate
Fase 1 del issue 0015. Tipos Go en functions/infra/migration.go con metadata en
types/infra/. Funciones puras: MigrationParse (parsea filename NNN_name.sql +
bloques -- +up/-- +down) y MigrationValidate (verifica secuencia, huecos,
duplicados, bloques vacios). 16 tests, todos pasan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:34 +02:00
egutierrez ab868bcea7 test: tests Go para funciones email SMTP
Tests para builders puros (build_html, build_text, with_attachment),
template_render, smtp_connect y smtp_send con mock TCP server.
Todos los tests pasan: 18 casos cubriendo pureza, inmutabilidad y envio SMTP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:18 +02:00
egutierrez cdcdb04d01 docs: cerrar issue 0023 — testing utilities implementadas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:16 +02:00
egutierrez 53deb8e9a8 feat: tipos y funciones email SMTP en Go (infra)
Tipos: EmailAttachment, EmailMessage, SMTPConfig.
Puras: email_build_html, email_build_text, email_with_attachment, email_template_render.
Impuras: smtp_connect (TLS/STARTTLS/plain), smtp_send (MIME multipart con adjuntos).
Solo stdlib: net/smtp, crypto/tls, text/template, mime/multipart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:13 +02:00
egutierrez 38fbb222bf docs: cerrar issue 0013
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:01:03 +02:00
egutierrez f7a4f26cf0 feat: cola de jobs asincrona basada en SQLite (issue 0013)
Implementa el subsistema completo de background jobs para apps Go en el
dominio infra. 9 funciones + 3 tipos + 17 tests, todos pasando.

- Tipos: Job (product), JobQueue (product), JobStatus (sum) con
  JobHandler, EnqueueOption y WorkerOption usando functional options pattern
- job_queue_create: CREATE TABLE + indices + WAL mode
- job_enqueue: INSERT con UUID (github.com/google/uuid), WithPriority/WithScheduledAt/WithMaxAttempts
- job_dequeue: SELECT+UPDATE atomico en transaccion exclusiva, filtro por jobTypes
- job_complete / job_fail: transiciones de estado; fail → dead cuando attempts >= max_attempts
- job_status_summary: pura, formatea conteo de jobs por estado
- job_worker: poll loop bloqueante, context-cancelable, graceful shutdown
- job_worker_pool: N workers con golang.org/x/sync/errgroup
- job_cleanup: DELETE jobs terminales mas viejos que olderThan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:44 +02:00
egutierrez 59eea5d0f1 chore: re-indexar registry.db con testing-utils (8 funciones, 2 tipos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:39 +02:00
egutierrez de64da7bbc feat: funciones impuras de testing (http server, db setup/seed, fixtures, env, logs) con tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:19 +02:00
egutierrez bb9c3d1bc3 feat: funciones puras assert_json_equal y assert_contains_all con tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:14 +02:00
egutierrez 97512e9a48 feat: tipos TestServer y TestDB para utilidades de testing en Go core
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:00:11 +02:00
egutierrez 8c1315b9d2 docs: cerrar issue 0009 — HTTP server functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:58:15 +02:00
egutierrez 02226d61f6 feat: funciones impuras HTTP — response, parse, logger, router, serve
Seis funciones de servidor HTTP con tests usando httptest:
- HTTPJSONResponse: escribe JSON con Content-Type y status code
- HTTPErrorResponse: escribe HTTPError como JSON estructurado
- HTTPParseBody: decode JSON con limite de bytes y campos estrictos
- HTTPLoggerMiddleware: loguea method/path/status/duration a io.Writer
- HTTPRouter: crea ServeMux con rutas Go 1.22+ (METHOD /path)
- HTTPServe: ListenAndServe con graceful shutdown por contexto

23 tests pasando, solo stdlib net/http.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:47 +02:00
egutierrez fd19cd222a feat: funciones puras HTTP — HTTPMiddlewareChain y HTTPCORSMiddleware
- HTTPMiddlewareChain: compone N middlewares preservando el orden (el primero es el mas externo)
- HTTPCORSMiddleware: genera Middleware con headers CORS configurables, maneja OPTIONS preflight con 204

Ambas son puras (sin I/O) y testeadas con httptest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:39 +02:00
egutierrez d8d72bb8d6 feat: tipos HTTP — Middleware, Route, HTTPError
Tres tipos Go en el paquete infra para construir servidores HTTP:
- Middleware: funcion que envuelve http.Handler (patron decorator)
- Route: struct con Method, Path y Handler para registrar rutas
- HTTPError: struct con Status, Code y Message para respuestas JSON de error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 01:57:34 +02:00
egutierrez 092f14eff0 merge: quick/cmake-path-and-start-script — CMake path fix, sqlite_api start.sh 2026-04-13 01:45:50 +02:00
egutierrez adfd5f63bb chore: actualizar path CMake a nueva ubicacion del dashboard + start.sh
cpp/CMakeLists.txt ahora referencia projects/fn_monitoring/apps/
en vez de apps/. Se añade start.sh para lanzar sqlite_api en
background con health check automatico.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:45:47 +02:00
egutierrez 74c9e39b58 merge: quick/fn-monitoring-project — Proyecto fn_monitoring, mover sqlite_api 2026-04-13 01:36:39 +02:00
egutierrez ccd123e062 feat: proyecto fn_monitoring — agrupa sqlite_api y registry_dashboard
Crea projects/fn_monitoring/ con project.md. Mueve sqlite_api de apps/
a projects/fn_monitoring/apps/. registry_dashboard (repo externo) tambien
se asocia al proyecto via dir_path actualizado. fn index detecta el
project_id automaticamente.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:36:32 +02:00
egutierrez 6877fcc70a merge: issue/0008-sqlite-api-web — API REST HTTP read-only para registry.db y operations.db 2026-04-13 01:29:03 +02:00
egutierrez 3ebda4fcca docs: cerrar issue 0008 — SQLite API Web completado
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:24:24 +02:00
egutierrez f9c1280964 feat: sqlite_api — API REST HTTP read-only para registry.db y operations.db
App service que expone las bases de datos SQLite del registry como endpoints
HTTP. Solo queries SELECT/PRAGMA, apertura read-only (?mode=ro), timeout 5s,
auto-discovery de operations.db, busqueda FTS5 directa, CORS habilitado.
Puerto default 8484.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:24:21 +02:00
egutierrez d2cbbdf600 merge: quick/projects-and-docker-deploy — Docker compose deploy, jupyter fixes, projects structure 2026-04-13 01:17:37 +02:00
egutierrez b717337b7b docs: regla projects, estructura projects/vaults, registry.db
Nueva regla projects.md que documenta como agrupar apps, analyses y vaults bajo
un tema comun en projects/{nombre}/. Actualiza INDEX.md con la entrada #15.
Crea directorios projects/ y vaults/ con .gitkeep (contenido real gitignored).
registry.db regenerado con los cambios del indice.
2026-04-13 01:17:25 +02:00
egutierrez 5b375cb822 fix: mejoras en jupyter launcher y kernel startup
write_jupyter_launcher ahora exporta IPYTHONDIR al directorio local .ipython/
para que el kernel cargue el startup correcto cuando se ejecuta desde projects/.
write_jupyter_registry_kernel usa descubrimiento inteligente de FN_REGISTRY_ROOT:
prioriza env var, luego path hardcoded, luego sube desde CWD buscando registry.db.
Esto permite que analyses dentro de projects/ encuentren el registry automaticamente.
2026-04-13 01:17:19 +02:00
egutierrez ee4e86ee2e feat: funcion docker_compose_remote_deploy bash
Funcion bash que despliega un stack Docker Compose en un host remoto via SSH.
Flujo: verificar SSH → git pull → docker-compose pull → docker-compose up -d → listar containers.
Soporta compose files adicionales y retorna JSON con status, containers y duracion.
2026-04-13 01:17:14 +02:00
egutierrez 5f8b71b528 merge: quick/deploy-infra-projects — Deploy infra, projects y vaults
Sistema de deploy SSH+systemd+rsync con funciones Go y Bash.
Soporte completo de projects y vaults en registry (schema, parser,
store, CLI). Regla deploy para agentes.
2026-04-12 17:30:45 +02:00
egutierrez 1e2582b068 docs: regla deploy, template project, gitignore projects/vaults
Añade regla deploy.md con arquitectura SSH+systemd+rsync, workflow
completo para agentes, y referencia a todas las funciones involucradas.
Actualiza INDEX.md con la nueva regla. Añade template project.md para
fn add -k project. Gitignore projects/*/ y vaults/*/ (contenido local,
solo manifests se versionan).
2026-04-12 17:30:01 +02:00
egutierrez ae33d02e75 feat: funciones bash de deploy — rsync_deploy y gitea_create_webhook
rsync_deploy sincroniza directorio local a remoto via SSH con
exclusiones estándar (.git, node_modules, *.db, etc.).
gitea_create_webhook crea webhook de push en un repo Gitea para
auto-deploy en cada commit.
2026-04-12 17:29:56 +02:00
egutierrez a06946e410 feat: funciones Go de deploy — systemd, VPS setup, deploy remoto
Nuevas funciones infra para deploy sin Docker: generación de units
systemd (pura), instalación/restart/status de servicios remotos via
SSH, setup inicial de VPS (crear dirs, usuario, permisos), y pipelines
de deploy completo (setup_vps_app, deploy_app_remote). Incluye tipo
DeployConfig con la configuración de deploy por app.
2026-04-12 17:29:52 +02:00
egutierrez 6f6bc714a9 feat: subcomando project en CLI con busqueda y listado integrado
Añade cmd/fn/project.go con subcomandos init, list, show y status
para gestionar proyectos desde la CLI. Integra projects en fn search,
fn list y fn show para que aparezcan junto a functions, types y apps.
También añade soporte para vaults en fn show y template project en
fn add -k project.
2026-04-12 17:29:46 +02:00
egutierrez 54e62ecb91 feat: soporte projects y vaults en registry
Añade tablas projects y vaults a registry.db con FTS5, modelos Go,
parser de project.md y vault.yaml, CRUD completo en store, hashing
determinista, validación, y soporte en el indexer para escanear
projects/{name}/ con sus apps, analysis y vaults anidados.
Migration 010 crea las tablas, triggers FTS5, y columna project_id
en apps/analysis. El indexer preserva records remotos (repo_url) al
reindexar, igual que apps/analysis.
2026-04-12 17:29:41 +02:00
egutierrez 1a3e77b0d5 merge: quick/new-bash-go-functions — Nuevas funciones Bash y Go multi-dominio
Batch de funciones nuevas para el registry:
- 12 funciones Bash shell (utilidades de scripting y git)
- 10 funciones Bash infra (instaladores y diagnostico)
- 12 funciones Bash cybersecurity (auditoria y hardening)
- 2 pipelines Bash (inicializacion de proyectos Go)
- 5 funciones Go core (strings y versiones)
- 7 funciones Go infra (gestion SSH config) + tipo SshConfigEntry
- 1 funcion Go shell (extract_script_description)
- 7 funciones Go tui (renderizado y terminal helpers)
2026-04-12 13:55:34 +02:00
egutierrez 8bc721d53b feat: add Go TUI rendering and terminal helper functions
7 funciones Go del dominio tui: apply_gradient (gradiente de color en texto),
draw_box y draw_separator (renderizado de cajas y separadores con box_chars),
load_ascii_art (carga de arte ASCII desde archivos), normalize_terminal_output
y strip_ansi (limpieza de salida de terminal), read_dir_autocomplete
(autocompletado de rutas de directorio). Incluye box_chars.go como helper
de caracteres Unicode para bordes.
2026-04-12 13:54:54 +02:00
egutierrez 6d73e1b4be feat: add Go extract_script_description function
Funcion Go pura del dominio shell que extrae la descripcion de un script Bash
parseando el header del archivo. Busca comentarios con formato estandar y
retorna la primera linea de descripcion encontrada. Util para indexar scripts
automaticamente.
2026-04-12 13:54:48 +02:00
egutierrez f2753e6fff feat: add Go SSH config management functions and type
7 funciones Go del dominio infra para gestion programatica de ~/.ssh/config:
ssh_config_parse (parser de bloques Host/Match), ssh_config_read (lectura del
archivo), ssh_config_find (busqueda por host), ssh_config_add_entry y
ssh_config_remove_entry (CRUD), ssh_config_render (serializacion a texto),
ssh_config_write (escritura atomica). Incluye tipo SshConfigEntry (product type)
y tests unitarios del parser.
2026-04-12 13:54:43 +02:00
egutierrez 773bb3a523 feat: add Go core string and version utility functions
5 funciones Go puras del dominio core: parse_version y compare_versions para
parsing y comparacion semantica de versiones, longest_common_prefix para
encontrar el prefijo comun mas largo entre strings, rel_or_full para devolver
rutas relativas cuando es posible, y split_command_and_arg para separar
comandos de sus argumentos. Todas sin dependencias externas.
2026-04-12 13:54:36 +02:00
egutierrez ae1c69eee0 feat: add bash pipeline functions for Go project initialization
2 pipelines Bash: init_go_module (inicializa un modulo Go con go mod init y
estructura de directorios estandar) e init_go_project (scaffolding completo
de proyecto Go con cmd/, internal/, configs y Makefile). Componen funciones
shell existentes del registry.
2026-04-12 13:54:30 +02:00
egutierrez e76a5e5ab1 feat: add bash cybersecurity audit and hardening functions
12 funciones Bash del dominio cybersecurity: auditoria de red y servicios
(analyze_dns, audit_http_headers, inspect_ssl_cert, list_active_connections,
enumerate_subdomains, geolocate_ip), auditoria de sistema (audit_ssh_config,
check_firewall, detect_suspicious_users), y utilidades crypto (encrypt_file,
generate_password, verify_file_hash). Dominio nuevo en bash/functions/.
2026-04-12 13:54:25 +02:00
egutierrez 94efefc7bf feat: add bash infra installer and diagnostic functions
10 funciones Bash del dominio infra: instaladores de herramientas de desarrollo
(install_go, install_nodejs, install_pnpm, install_python312, install_uv,
install_volta, install_wails), diagnostico del sistema (analyze_disk_space,
detect_wsl, list_listening_ports). Automatizan la configuracion del entorno
de desarrollo en Linux/WSL.
2026-04-12 13:54:21 +02:00
egutierrez 8f45b40528 feat: add bash shell utility functions
12 funciones Bash del dominio shell: utilidades de scripting (bash_log,
bash_colors, bash_check_deps, bash_confirm, bash_handle_error, bash_safe_run),
manipulacion de texto (convert_text_case), estructura de proyectos
(create_project_structure), y operaciones git (git_clean_branches,
git_log_visual, git_push_all_remotes, git_repo_status). Cada una con su
.sh y .md de frontmatter.
2026-04-12 13:54:15 +02:00
egutierrez ac9965220d merge: issue/0007-dag-engine — Motor de DAGs con CLI, web frontend y SQLite
Reemplaza Dagu con implementacion propia compatible con formato YAML existente.
Incluye parser, validador, topo sort, process manager, execution store SQLite,
scheduler cron, CLI (run/list/status/validate/server) y frontend React/Mantine.
2026-04-12 13:08:26 +02:00
egutierrez 1344e557e5 chore: update gitignore files
Agrega prompts/ al gitignore raiz. Actualiza dag_engine/.gitignore
con patrones estandar para Go, frontend y editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:08:13 +02:00
egutierrez 2721b9cc8f chore: close issues 0007a-e, update feature flag and sources manifest
Enable dag-engine feature flag, document dagu as analyzed (GPL-3.0,
no code extracted), move all 0007 issues to completed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:06:17 +02:00
egutierrez d9414e4cba feat: add dag_engine app — CLI + web frontend for DAG execution (0007e)
Full DAG engine app with CLI subcommands (run, list, status, validate, server)
and React/Mantine web frontend. Uses net/http + embedded Vite build. SQLite
store for run history. Scheduler with cron_ticker for automated execution.
Compatible with existing dagu YAML format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:36 +02:00
egutierrez 7aa7790931 feat: add process manager and execution store types (0007b, 0007c)
Process spawn/wait/kill functions for subprocess management with output
capture, timeout, and process group cleanup. DagRun and DagStepResult
types for SQLite execution persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:13 +02:00
egutierrez c3dfc9315f feat: add DAG core functions — parse, validate, topo sort, resolve env, cron match (0007a, 0007d)
Pure functions for parsing dagu-compatible YAML, validating DAG structure,
topological sorting with parallel levels (Kahn's algorithm), and env variable
resolution. Also adds cron_match for schedule matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:05:05 +02:00
egutierrez cb96e85b69 merge: quick/issue-0008-sqlite-api-web — SQLite API Web service 2026-04-08 01:29:11 +02:00
742 changed files with 54794 additions and 117 deletions
+34 -7
View File
@@ -3,9 +3,11 @@
Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes.
**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.
**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`
---
@@ -66,6 +68,13 @@ sqlite3 registry.db ".schema"
- Extraidos automaticamente por `fn index` desde los archivos de test
- 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):**
- `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
@@ -93,6 +102,7 @@ fn-registry/
cmd/fn/ # CLI principal
docs/ # Specs de diseño
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 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)
fn ops init [path]
fn ops entity add|list|show|delete
@@ -235,16 +252,26 @@ analysis/
### Crear un analisis nuevo
```bash
# Basico
fn run init_jupyter_analysis finanzas
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.
# 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 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
+2
View File
@@ -17,3 +17,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
| 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 |
| 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 |
+9
View File
@@ -7,3 +7,12 @@ Criterios para decidir:
- **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.`).
## 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
+134
View File
@@ -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`.
+88
View File
@@ -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 = ?;
```
+17
View File
@@ -37,12 +37,28 @@ python/.venv/
apps/*/
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_modules/
# Sources — repos externos clonados (solo se versiona el manifest)
sources/*/
# External — symlinks a repos ajenos (ej: repo_Claude con skills/commands)
external/
# Worktrees — git worktrees para issues paralelos (parallel-fix-issues)
worktrees/
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
temp/
# C++ build artifacts
cpp/build/
@@ -55,3 +71,4 @@ Thumbs.db
broken_paths.txt
imgui.ini
prompts/
+18
View File
@@ -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
+47
View File
@@ -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)
})
}
+86
View File
@@ -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).
+34
View File
@@ -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)
}
+482
View File
@@ -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
}
+12
View File
@@ -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>
+28
View File
@@ -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": {},
},
};
+32
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+18
View File
@@ -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} &middot; {run.Trigger} &middot;{" "}
{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>
);
}
+66
View File
@@ -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 }[];
}
+21
View File
@@ -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"]
}
+15
View File
@@ -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",
},
});
+48
View File
@@ -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
+168
View File
@@ -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=
+76
View File
@@ -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})
}
+59
View File
@@ -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,
})
}
}
+27
View File
@@ -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)
}
}
+336
View File
@@ -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)
}
}
+30
View File
@@ -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))
})
}
+188
View File
@@ -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);
+231
View File
@@ -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,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.
+170
View File
@@ -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
+46
View File
@@ -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).
+97
View File
@@ -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
+49
View File
@@ -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.
+115
View File
@@ -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
+45
View File
@@ -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.
+75
View File
@@ -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
+40
View File
@@ -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.
+64
View File
@@ -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
+40
View File
@@ -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.
+136
View File
@@ -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
+45
View File
@@ -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.
+88
View File
@@ -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
+45
View File
@@ -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`.
+100
View File
@@ -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
+46
View File
@@ -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.
+123
View File
@@ -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
+52
View File
@@ -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.
+77
View File
@@ -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).
+150
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
---
id: write_analysis_md_bash_infra
name: write_analysis_md
kind: function
lang: bash
domain: infra
version: 1.0.0
purity: impure
signature: "write_analysis_md(analysis_dir: string, name: string, description: string, tags_csv: string) -> string"
description: "Genera un archivo analysis.md con frontmatter valido para el registry. Calcula dir_path relativo a FN_REGISTRY_ROOT (o lo deduce buscando registry.db hacia arriba). Acepta tags como CSV."
tags: [analysis, frontmatter, registry, markdown]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
example: "source write_analysis_md.sh && write_analysis_md projects/aurgi/analysis/ventas ventas 'Analisis de ventas' 'aurgi,ventas'"
tested: false
file_path: "bash/functions/infra/write_analysis_md.sh"
params:
- name: analysis_dir
desc: "Directorio del analisis donde se escribira analysis.md"
- name: name
desc: "Nombre del analisis (se usa en frontmatter name)"
- name: description
desc: "Descripcion breve del analisis"
- name: tags_csv
desc: "Tags separados por coma (opcional)"
output: "Ruta absoluta del analysis.md creado"
---
## Notas
Forma parte del workflow de creacion rapida de analyses dentro de proyectos.
Requiere que `analysis_dir` exista fisicamente antes de llamar (para resolver path absoluto). Normalmente se llama dentro del pipeline `init_jupyter_analysis` despues de crear la estructura de carpetas.
El `dir_path` del frontmatter debe ser relativo a la raiz del registry para que `fn index` lo enlace correctamente al `project_id` si esta bajo `projects/{nombre}/analysis/`.
+67
View File
@@ -0,0 +1,67 @@
# write_analysis_md
# -----------------
# Genera un archivo analysis.md con frontmatter valido para el registry.
# El dir_path se calcula relativo a FN_REGISTRY_ROOT.
#
# USO (sourced):
# source write_analysis_md.sh
# write_analysis_md <analysis_dir> <name> <description> [tags_csv]
#
# EJEMPLOS:
# write_analysis_md projects/aurgi/analysis/sale_prices sale_prices "Comprobacion precios"
# write_analysis_md analysis/finanzas finanzas "Exploracion gastos" "finanzas,personal"
write_analysis_md() {
local analysis_dir="${1:-}"
local name="${2:-}"
local description="${3:-}"
local tags_csv="${4:-}"
if [ -z "$analysis_dir" ] || [ -z "$name" ]; then
echo "Uso: write_analysis_md <analysis_dir> <name> <description> [tags_csv]" >&2
return 1
fi
# dir_path relativo a FN_REGISTRY_ROOT
local registry_root="${FN_REGISTRY_ROOT:-}"
if [ -z "$registry_root" ]; then
# Intenta deducirlo: buscar registry.db hacia arriba
local probe="$(cd "$analysis_dir" && pwd)"
while [ "$probe" != "/" ] && [ ! -f "$probe/registry.db" ]; do
probe="$(dirname "$probe")"
done
registry_root="$probe"
fi
local abs_dir="$(cd "$analysis_dir" && pwd)"
local rel_dir="${abs_dir#${registry_root}/}"
# Construir array YAML de tags
local tags_yaml="[]"
if [ -n "$tags_csv" ]; then
tags_yaml="[$(echo "$tags_csv" | sed 's/,/, /g')]"
fi
local md_path="${analysis_dir}/analysis.md"
cat > "$md_path" << EOF
---
name: ${name}
lang: py
domain: datascience
description: "${description}"
tags: ${tags_yaml}
uses_functions: []
uses_types: []
framework: "jupyterlab"
entry_point: "notebooks/main.ipynb"
dir_path: "${rel_dir}"
repo_url: ""
---
## Notas
${description}
EOF
echo "$md_path"
}
@@ -34,6 +34,11 @@ echo $PORT > .jupyter-port
source .venv/bin/activate 2>/dev/null || true
# IPython startup: cargar .ipython/ local (FN_REGISTRY_ROOT, helpers, sys.path)
if [ -d "$(pwd)/.ipython" ]; then
export IPYTHONDIR="$(pwd)/.ipython"
fi
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
echo "ERROR: jupyter-collaboration no esta instalado"
echo "Instala con: uv add jupyter-collaboration"
@@ -33,7 +33,24 @@ import sqlite3
from pathlib import Path
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
FN_REGISTRY_ROOT = Path("${registry_root}")
# Prioridad: env var > path hardcoded > descubrimiento automatico
def _discover_registry_root():
if os.environ.get("FN_REGISTRY_ROOT"):
return Path(os.environ["FN_REGISTRY_ROOT"]).resolve()
hardcoded = Path("${registry_root}")
if (hardcoded / "registry.db").exists():
return hardcoded
# Subir desde CWD hasta encontrar registry.db
p = Path.cwd()
for _ in range(10):
if (p / "registry.db").exists():
return p
if p.parent == p:
break
p = p.parent
return hardcoded
FN_REGISTRY_ROOT = _discover_registry_root()
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
# ── sys.path: importar funciones Python del registry ────────
+113
View File
@@ -0,0 +1,113 @@
---
name: init_api_app
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_api_app(nombre: string, [--port N], [--with-auth], [--with-db], [--with-ops]) -> void"
description: "Scaffold de Go HTTP API app en apps/{nombre}/. Genera main.go, handlers.go, config.go, migrations, Makefile, .env.example, .gitignore y app.md con frontmatter correcto. Compone funciones del registry (http_serve, http_router, http_middleware_chain, migration_up) y verifica con go vet."
tags: [init, scaffold, api, http, pipeline, bash, launcher]
uses_functions:
- assert_command_exists_bash_shell
- http_serve_go_infra
- http_router_go_infra
- http_middleware_chain_go_infra
- http_logger_middleware_go_infra
- http_cors_middleware_go_infra
- http_json_response_go_infra
- http_error_response_go_infra
- migration_up_go_infra
- jwt_generate_go_infra
- password_hash_go_infra
- password_verify_go_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: nombre
desc: "nombre de la app a crear (se usa como dir y binario en apps/{nombre}/)"
- name: "--port"
desc: "puerto por defecto del servidor HTTP (opcional, default 8080)"
- name: "--with-auth"
desc: "anade jwt_middleware, handlers login/register, tabla users en migration"
- name: "--with-db"
desc: "anade store.go con helpers CRUD y setup de SQLite al arrancar"
- name: "--with-ops"
desc: "anade fn ops init para crear operations.db con schema completo"
output: "estructura apps/{nombre}/ lista para ejecutarse con `make run`; si go vet falla, reporta error antes de declarar exito."
tested: false
tests: []
test_file_path: ""
example: "fn run init_api_app my_service --with-db"
file_path: "bash/functions/pipelines/init_api_app.sh"
---
## Sinopsis
```bash
fn run init_api_app <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]
```
## Ejemplo rapido
```bash
fn run init_api_app billing_api --port 8090 --with-auth --with-db
cd apps/billing_api
cp .env.example .env
make run
# → starting billing_api on :8090
curl localhost:8090/health # {"status":"ok"}
```
## Archivos generados
| Archivo | Descripcion |
|---------|-------------|
| `main.go` | Entry point con HTTPServe, router, middleware chain, graceful shutdown |
| `handlers.go` | Handlers de ejemplo (`/health`, `/api/v1/status`) con HTTPJSONResponse |
| `config.go` | Struct Config leida desde env vars con defaults |
| `migrations/001_initial.sql` | Schema inicial con tabla `items` (id, name, timestamps) |
| `Makefile` | Targets `build`, `run`, `dev`, `test`, `vet`, `clean` |
| `.env.example` | Variables PORT, DB_PATH, CORS_ORIGINS (+ JWT_SECRET con auth) |
| `.gitignore` | Binario, *.db, .env, IDE files |
| `go.mod` | Modulo Go con replace directive a fn-registry |
| `app.md` | Frontmatter con tag `service`, uses_functions reales, dir_path |
Con `--with-auth` anade ademas:
- `auth.go` — handlers `/auth/login`, `/auth/register` usando JWTGenerate, PasswordHash, PasswordVerify
- `migrations/002_users.sql` — tablas `users` y `sessions`
Con `--with-db` anade:
- `store.go` — struct Store con `NewStore`, `Ping` para acceso a SQLite
## Flags
| Flag | Efecto |
|------|--------|
| `--port N` | Puerto por defecto en config y .env.example (default: 8080) |
| `--with-auth` | Auth con JWT + bcrypt + tabla users |
| `--with-db` | Store con helpers CRUD + setup SQLite |
| `--with-ops` | fn ops init para operations.db |
## Post-setup
```bash
cd apps/{nombre}
cp .env.example .env
make run # Arranca el server
make dev # Hot via go run
make test # Tests con fts5
```
## Notas
Pipeline impuro: escribe archivos al disco, ejecuta `go mod tidy` y `go vet`.
Compone heredocs para generar los archivos. Cada heredoc es reemplazable si alguna funcion del registry cambia de firma — ajustar el heredoc correspondiente.
Abort si `apps/{nombre}` ya existe para no sobrescribir.
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI.
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env bash
# init_api_app
# ------------
# Scaffold de una Go HTTP API app en apps/{nombre}/.
#
# Genera main.go, handlers.go, config.go, migrations/001_initial.sql,
# Makefile, .env.example, .gitignore y app.md con frontmatter correcto.
# El boilerplate importa funciones del registry (http_serve, http_router,
# http_middleware_chain, migration_up, etc.) y verifica que compila con
# `go vet` al final.
#
# USO:
# ./init_api_app.sh <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]
#
# FLAGS:
# --port N Puerto por defecto (default: 8080)
# --with-auth Anade JWT middleware, login/register, tabla users
# --with-db Anade store.go con helpers CRUD y setup de SQLite
# --with-ops Anade `fn ops init` para crear operations.db con schema completo
#
# EJEMPLO:
# ./init_api_app.sh my_service
# ./init_api_app.sh billing_api --port 8090 --with-auth --with-db
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# Source funciones atomicas del registry
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
# ── Parsing de argumentos ────────────────────────────────────
NOMBRE=""
PORT="8080"
WITH_AUTH="false"
WITH_DB="false"
WITH_OPS="false"
while [ $# -gt 0 ]; do
case "$1" in
--port)
PORT="$2"; shift 2 ;;
--with-auth)
WITH_AUTH="true"; shift ;;
--with-db)
WITH_DB="true"; shift ;;
--with-ops)
WITH_OPS="true"; shift ;;
-h|--help)
grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
-*)
echo "Flag desconocido: $1" >&2 ; exit 1 ;;
*)
if [ -z "$NOMBRE" ]; then
NOMBRE="$1"
else
echo "Argumento extra ignorado: $1" >&2
fi
shift ;;
esac
done
if [ -z "$NOMBRE" ]; then
echo "Uso: $0 <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]" >&2
exit 1
fi
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
if [ -d "$APP_DIR" ]; then
echo "ERROR: ${APP_DIR} ya existe. Abortando para no sobrescribir." >&2
exit 1
fi
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT API APP: ${NOMBRE}"
echo " Directorio: ${APP_DIR}"
echo " Puerto: ${PORT}"
echo " Auth: ${WITH_AUTH}"
echo " DB: ${WITH_DB}"
echo " Ops: ${WITH_OPS}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── 1. Verificar Go ──────────────────────────────────────────
echo "[1/7] Verificando herramientas..."
assert_command_exists go
echo " Go: $(go version)"
# ── 2. Crear estructura ──────────────────────────────────────
echo "[2/7] Creando estructura..."
mkdir -p "$APP_DIR/migrations"
echo " ${APP_DIR}/"
echo " ${APP_DIR}/migrations/"
# ── 3. Escribir go.mod ───────────────────────────────────────
echo "[3/7] Creando go.mod..."
cat > "$APP_DIR/go.mod" <<EOF
module ${NOMBRE}
go 1.25.0
require (
fn-registry v0.0.0-00010101000000-000000000000
github.com/mattn/go-sqlite3 v1.14.37
)
replace fn-registry => ${REGISTRY_ROOT}
EOF
# ── 4. Escribir archivos Go ──────────────────────────────────
echo "[4/7] Escribiendo archivos Go..."
# config.go
cat > "$APP_DIR/config.go" <<'EOF'
package main
import (
"os"
)
// Config contiene la configuracion runtime de la app.
type Config struct {
AppName string
Port string
DBPath string
CORSOrigins []string
}
// LoadConfig lee la configuracion desde variables de entorno con defaults sensatos.
func LoadConfig() Config {
return Config{
AppName: getenv("APP_NAME", "__APP_NAME__"),
Port: getenv("PORT", "__PORT__"),
DBPath: getenv("DB_PATH", "__APP_NAME__.db"),
CORSOrigins: []string{getenv("CORS_ORIGINS", "*")},
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
EOF
sed -i "s/__APP_NAME__/${NOMBRE}/g; s/__PORT__/${PORT}/g" "$APP_DIR/config.go"
# handlers.go
cat > "$APP_DIR/handlers.go" <<'EOF'
package main
import (
"net/http"
"fn-registry/functions/infra"
)
// healthHandler responde {"status":"ok"} en GET /health.
func healthHandler(w http.ResponseWriter, r *http.Request) {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{
"status": "ok",
})
}
// statusHandler es un handler de ejemplo en GET /api/v1/status.
func statusHandler(w http.ResponseWriter, r *http.Request) {
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"app": "__APP_NAME__",
"version": "0.1.0",
})
}
EOF
sed -i "s/__APP_NAME__/${NOMBRE}/g" "$APP_DIR/handlers.go"
# main.go
# Construccion incremental segun flags
MAIN_IMPORTS='"context"
"database/sql"
"log"
"os"
"os/signal"
"syscall"
_ "github.com/mattn/go-sqlite3"
"fn-registry/functions/infra"'
MAIN_MIGRATION=""
if [ "$WITH_DB" = "true" ]; then
MAIN_MIGRATION=' // Migrations
if _, err := infra.MigrationUp(db, "migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
'
else
MAIN_MIGRATION=' // Migrations (only if migrations dir has content)
if _, err := os.Stat("migrations"); err == nil {
if _, err := infra.MigrationUp(db, "migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
}
'
fi
cat > "$APP_DIR/main.go" <<EOF
package main
import (
${MAIN_IMPORTS}
)
func main() {
cfg := LoadConfig()
// DB
db, err := sql.Open("sqlite3", cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
${MAIN_MIGRATION}
// Routes
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: http.HandlerFunc(healthHandler)},
{Method: "GET", Path: "/api/v1/status", Handler: http.HandlerFunc(statusHandler)},
}
mux := infra.HTTPRouter(routes)
// Middleware chain (outer → inner)
chain := infra.HTTPMiddlewareChain(
infra.HTTPLoggerMiddleware(os.Stdout),
infra.HTTPCORSMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
)
handler := chain(mux)
// Graceful shutdown via context
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
addr := ":" + cfg.Port
log.Printf("starting %s on %s", cfg.AppName, addr)
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
log.Fatalf("http serve: %v", err)
}
log.Println("server stopped")
}
EOF
# main.go necesita import net/http tambien
# rehacerlo con el import bien
cat > "$APP_DIR/main.go" <<EOF
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
_ "github.com/mattn/go-sqlite3"
"fn-registry/functions/infra"
)
func main() {
cfg := LoadConfig()
// DB
db, err := sql.Open("sqlite3", cfg.DBPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
${MAIN_MIGRATION}
// Routes
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: http.HandlerFunc(healthHandler)},
{Method: "GET", Path: "/api/v1/status", Handler: http.HandlerFunc(statusHandler)},
}
mux := infra.HTTPRouter(routes)
// Middleware chain
chain := infra.HTTPMiddlewareChain(
infra.HTTPLoggerMiddleware(os.Stdout),
infra.HTTPCORSMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
)
handler := chain(mux)
// Graceful shutdown via signal-aware context
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
addr := ":" + cfg.Port
log.Printf("starting %s on %s", cfg.AppName, addr)
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
log.Fatalf("http serve: %v", err)
}
log.Println("server stopped")
_ = db
}
EOF
# --- Auth opcional ---
if [ "$WITH_AUTH" = "true" ]; then
cat > "$APP_DIR/auth.go" <<'EOF'
package main
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"fn-registry/functions/infra"
)
// LoginRequest es el body JSON del endpoint /auth/login.
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// RegisterRequest es el body JSON del endpoint /auth/register.
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// authRegisterHandler crea un usuario nuevo.
func authRegisterHandler(db *sql.DB, jwtSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "invalid body"})
return
}
hash, err := infra.PasswordHash(req.Password, 10)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()})
return
}
if _, err := db.Exec(
`INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)`,
req.Email, hash, time.Now().Format(time.RFC3339),
); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 409, Code: "duplicate_user", Message: err.Error()})
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, map[string]string{"email": req.Email})
}
}
// authLoginHandler valida credenciales y emite un JWT.
func authLoginHandler(db *sql.DB, jwtSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "invalid body"})
return
}
var id int64
var hash string
err := db.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, req.Email).Scan(&id, &hash)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "invalid credentials"})
return
}
if err := infra.PasswordVerify(req.Password, hash); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "invalid credentials"})
return
}
claims := infra.JWTClaims{
Subject: req.Email,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
IssuedAt: time.Now().Unix(),
}
token, err := infra.JWTGenerate(claims, jwtSecret)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "jwt_error", Message: err.Error()})
return
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{"token": token})
}
}
EOF
fi
# --- Store opcional ---
if [ "$WITH_DB" = "true" ]; then
cat > "$APP_DIR/store.go" <<'EOF'
package main
import (
"database/sql"
"fmt"
)
// Store encapsula el acceso a la base de datos.
type Store struct {
db *sql.DB
}
// NewStore crea una Store con el pool de conexiones dado.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Ping verifica conectividad con la base de datos.
func (s *Store) Ping() error {
if err := s.db.Ping(); err != nil {
return fmt.Errorf("ping: %w", err)
}
return nil
}
EOF
fi
# ── 5. Migracion inicial ─────────────────────────────────────
echo "[5/7] Escribiendo migrations/001_initial.sql..."
cat > "$APP_DIR/migrations/001_initial.sql" <<EOF
-- 001_initial — schema inicial para ${NOMBRE}
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- DOWN
DROP TABLE IF EXISTS items;
EOF
if [ "$WITH_AUTH" = "true" ]; then
cat > "$APP_DIR/migrations/002_users.sql" <<'EOF'
-- 002_users — tabla users para auth
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
metadata TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- DOWN
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
EOF
fi
# ── 6. Makefile / .env.example / .gitignore / app.md ─────────
echo "[6/7] Escribiendo Makefile, .env.example, .gitignore..."
cat > "$APP_DIR/Makefile" <<EOF
.PHONY: build run test vet clean dev
BIN=${NOMBRE}
build:
CGO_ENABLED=1 go build -tags fts5 -o \$(BIN) .
run: build
./\$(BIN)
dev:
CGO_ENABLED=1 go run .
test:
CGO_ENABLED=1 go test -tags fts5 -v ./...
vet:
CGO_ENABLED=1 go vet -tags fts5 ./...
clean:
rm -f \$(BIN) *.db *.db-shm *.db-wal
EOF
cat > "$APP_DIR/.env.example" <<EOF
# Configuracion de ${NOMBRE}
APP_NAME=${NOMBRE}
PORT=${PORT}
DB_PATH=${NOMBRE}.db
CORS_ORIGINS=*
EOF
if [ "$WITH_AUTH" = "true" ]; then
cat >> "$APP_DIR/.env.example" <<EOF
# Auth
JWT_SECRET=change-me-in-production
EOF
fi
cat > "$APP_DIR/.gitignore" <<EOF
# Binario
${NOMBRE}
# SQLite
*.db
*.db-shm
*.db-wal
# Env
.env
# IDE
.idea/
.vscode/
EOF
# app.md con frontmatter
USES_FUNCTIONS=' - http_serve_go_infra
- http_router_go_infra
- http_middleware_chain_go_infra
- http_cors_middleware_go_infra
- http_logger_middleware_go_infra
- http_json_response_go_infra
- http_error_response_go_infra
- migration_up_go_infra'
if [ "$WITH_AUTH" = "true" ]; then
USES_FUNCTIONS="${USES_FUNCTIONS}
- jwt_generate_go_infra
- password_hash_go_infra
- password_verify_go_infra"
fi
cat > "$APP_DIR/app.md" <<EOF
---
name: ${NOMBRE}
lang: go
domain: tools
description: "API HTTP generada por init_api_app."
tags: [service]
uses_functions:
${USES_FUNCTIONS}
uses_types:
- Route_go_infra
- Middleware_go_infra
- HTTPError_go_infra
framework: "net/http"
entry_point: "main.go"
dir_path: "apps/${NOMBRE}"
---
## Notas
App scaffoldeada por \`init_api_app\`. Puerto ${PORT}. Health check en \`/health\`.
Ejecutar: \`make run\` o \`make dev\` para hot reload.
EOF
# ── 7. Verificar con go vet ──────────────────────────────────
echo "[7/7] Verificando con go vet..."
(
cd "$APP_DIR"
if go mod tidy 2>&1 | tail -3; then
:
fi
if CGO_ENABLED=1 go vet -tags fts5 ./... 2>&1; then
echo " go vet OK"
else
echo " WARN: go vet reporto problemas (revisa el output arriba)" >&2
fi
)
echo ""
echo "════════════════════════════════════════════════════════════"
echo " API APP '${NOMBRE}' LISTA"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo " cd apps/${NOMBRE}"
echo " cp .env.example .env"
echo " make run"
echo ""
echo " Health check:"
echo " curl localhost:${PORT}/health"
echo ""
+110
View File
@@ -0,0 +1,110 @@
---
name: init_cli_app
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_cli_app(nombre: string, [--with-tui]) -> void"
description: "Scaffold de Go CLI app con subcomandos (version/status/help) routed via os.Args. Con --with-tui genera model.go con un modelo Bubbletea fullscreen (lista filtrable + spinner + dark theme) y main.go arranca la TUI en modo fullscreen. Sin dependencias de cobra/urfave — consistente con las apps del registry."
tags: [init, scaffold, cli, tui, pipeline, bash, launcher]
uses_functions:
- assert_command_exists_bash_shell
- new_base_model_go_tui
- dark_styles_go_tui
- run_fullscreen_go_tui
- new_spinner_go_tui
- new_filtered_list_go_tui
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: nombre
desc: "nombre de la CLI app (apps/{nombre}/); se usa como binario y como modulo Go"
- name: "--with-tui"
desc: "anade model.go con modelo Bubbletea (lista + spinner + dark theme) y arranca TUI fullscreen al invocar sin args"
output: "CLI app en apps/{nombre}/ con main.go (subcommand routing), cmd_version.go, cmd_status.go, Makefile con targets build/run/install/test/vet/clean. Con --with-tui anade model.go y el default sin args arranca TUI."
tested: false
tests: []
test_file_path: ""
example: "fn run init_cli_app my_cli --with-tui"
file_path: "bash/functions/pipelines/init_cli_app.sh"
---
## Sinopsis
```bash
fn run init_cli_app <nombre> [--with-tui]
```
## Ejemplo rapido
```bash
# CLI clasica
fn run init_cli_app mytool
cd apps/mytool
make build
./mytool version
./mytool status
# CLI + TUI fullscreen
fn run init_cli_app deploy_helper --with-tui
cd apps/deploy_helper
make build
./deploy_helper # arranca TUI
./deploy_helper version # subcomando
```
## Archivos generados
| Archivo | Descripcion |
|---------|-------------|
| `main.go` | Entry con `switch os.Args[1]`; subcomandos: version, status, help |
| `cmd_version.go` | Imprime nombre + version |
| `cmd_status.go` | Imprime app/version/go/os/arch |
| `go.mod` | Modulo Go con replace `fn-registry`; con `--with-tui` agrega bubbletea/bubbles/lipgloss |
| `Makefile` | Targets build, run (ARGS=...), install (~/.local/bin/), test, vet, clean |
| `.gitignore` | Binario + IDE files |
| `app.md` | Tag `cli` (o `cli,tui,bubbletea` con `--with-tui`) |
Con `--with-tui` anade:
- `model.go``Model` con spinner (spinner.Dot), lista Bubbletea con 4 items de ejemplo, dark theme (lipgloss con colores 7D56F4 / 06B6D4), keys enter/q/ctrl+c
- `main.go` arranca la TUI con `tea.NewProgram(model, tea.WithAltScreen())` si no hay args
## Flags
| Flag | Efecto |
|------|--------|
| `--with-tui` | Anade model.go con Bubbletea y TUI fullscreen como default |
## Post-setup
```bash
cd apps/{nombre}
make build # construye ./{nombre}
./{nombre} version
./{nombre} status
make install # copia a ~/.local/bin/{nombre}
make run ARGS="status" # build + run con argumentos
```
## Notas
Pipeline impuro: genera archivos, ejecuta `go mod tidy` y `go vet` al final.
Los ejemplos del modelo TUI (items "Deploy", "Status", "Logs", "Exit") son
placeholders — reemplazar con la logica real de la app. El modelo usa los
componentes estandar de bubbles (`list`, `spinner`) y lipgloss para estilos.
Las funciones del registry `new_base_model_go_tui`, `dark_styles_go_tui`,
`run_fullscreen_go_tui`, `new_spinner_go_tui`, `new_filtered_list_go_tui`
son referenciadas en el frontmatter como deps conceptuales aunque el
scaffold inline el codigo Bubbletea directamente (las funciones del registry
son stubs que delegan a devfactory/tui).
Abort si `apps/{nombre}/` ya existe.
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI (aunque
una CLI con TUI interactiva normalmente no se lanza como subprocess).
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env bash
# init_cli_app
# ------------
# Scaffold de Go CLI app con subcomandos, opcionalmente con TUI Bubbletea.
#
# Genera main.go con routing de subcomandos (os.Args + switch), cmd_version.go,
# cmd_status.go, Makefile, .gitignore, go.mod y app.md.
#
# Con --with-tui genera ademas model.go con un modelo Bubbletea base y main.go
# arranca la TUI con tea.NewProgram().Run() en modo fullscreen.
#
# USO:
# ./init_cli_app.sh <nombre> [--with-tui]
#
# EJEMPLOS:
# ./init_cli_app.sh my_cli
# ./init_cli_app.sh deploy_helper --with-tui
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
NOMBRE=""
WITH_TUI="false"
while [ $# -gt 0 ]; do
case "$1" in
--with-tui) WITH_TUI="true"; shift ;;
-h|--help) grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
-*) echo "Flag desconocido: $1" >&2 ; exit 1 ;;
*)
if [ -z "$NOMBRE" ]; then NOMBRE="$1"
else echo "Argumento extra ignorado: $1" >&2
fi
shift ;;
esac
done
if [ -z "$NOMBRE" ]; then
echo "Uso: $0 <nombre> [--with-tui]" >&2
exit 1
fi
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
if [ -d "$APP_DIR" ]; then
echo "ERROR: ${APP_DIR} ya existe. Abortando." >&2
exit 1
fi
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT CLI APP: ${NOMBRE}"
echo " Directorio: ${APP_DIR}"
echo " TUI: ${WITH_TUI}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── 1. Verificar Go ──────────────────────────────────────────
echo "[1/5] Verificando herramientas..."
assert_command_exists go
echo " Go: $(go version)"
# ── 2. Crear estructura ──────────────────────────────────────
echo "[2/5] Creando estructura..."
mkdir -p "$APP_DIR"
# go.mod
if [ "$WITH_TUI" = "true" ]; then
cat > "$APP_DIR/go.mod" <<EOF
module ${NOMBRE}
go 1.25.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
fn-registry v0.0.0-00010101000000-000000000000
)
replace fn-registry => ${REGISTRY_ROOT}
EOF
else
cat > "$APP_DIR/go.mod" <<EOF
module ${NOMBRE}
go 1.25.0
require (
fn-registry v0.0.0-00010101000000-000000000000
)
replace fn-registry => ${REGISTRY_ROOT}
EOF
fi
# ── 3. Archivos Go ───────────────────────────────────────────
echo "[3/5] Escribiendo archivos Go..."
# cmd_version.go — siempre existe
cat > "$APP_DIR/cmd_version.go" <<EOF
package main
import "fmt"
var version = "0.1.0"
func cmdVersion() {
fmt.Printf("${NOMBRE} %s\n", version)
}
EOF
# cmd_status.go — siempre existe
cat > "$APP_DIR/cmd_status.go" <<EOF
package main
import (
"fmt"
"runtime"
)
func cmdStatus() {
fmt.Printf("app: ${NOMBRE}\n")
fmt.Printf("version: %s\n", version)
fmt.Printf("go: %s\n", runtime.Version())
fmt.Printf("os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
EOF
if [ "$WITH_TUI" = "true" ]; then
# model.go — Bubbletea BaseModel con spinner y lista
cat > "$APP_DIR/model.go" <<EOF
package main
import (
"fmt"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// --- estilos (dark theme) ---
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#06B6D4"))
)
// --- item de la lista ---
type item struct {
title, desc string
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
// --- modelo raiz ---
type Model struct {
spinner spinner.Model
list list.Model
status string
quit bool
}
func NewModel() Model {
items := []list.Item{
item{title: "Deploy", desc: "Subir codigo al VPS"},
item{title: "Status", desc: "Ver estado de servicios"},
item{title: "Logs", desc: "Tail de logs en tiempo real"},
item{title: "Exit", desc: "Salir"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "${NOMBRE} — elige una accion"
l.Styles.Title = titleStyle
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = selectedStyle
return Model{
spinner: s,
list: l,
status: "listo",
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.quit = true
return m, tea.Quit
case "enter":
if it, ok := m.list.SelectedItem().(item); ok {
if it.title == "Exit" {
m.quit = true
return m, tea.Quit
}
m.status = fmt.Sprintf("seleccionado: %s", it.title)
}
}
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-4)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if m.quit {
return "adios!\n"
}
return fmt.Sprintf(
"%s\n\n%s %s\n%s",
m.list.View(),
m.spinner.View(),
m.status,
helpStyle.Render("enter: seleccionar · q: salir"),
)
}
EOF
# main.go con TUI
cat > "$APP_DIR/main.go" <<EOF
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
if len(os.Args) < 2 {
runTUI()
return
}
switch os.Args[1] {
case "tui":
runTUI()
case "version":
cmdVersion()
case "status":
cmdStatus()
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "comando desconocido: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func runTUI() {
p := tea.NewProgram(NewModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui error: %v\n", err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println(\`${NOMBRE} — CLI tool
Uso:
${NOMBRE} [comando]
Sin comando arranca la TUI fullscreen.
Comandos:
tui Arranca la TUI fullscreen (default)
version Imprime la version
status Muestra info del sistema
help Muestra esta ayuda\`)
}
EOF
else
# main.go sin TUI — subcommand routing clasico
cat > "$APP_DIR/main.go" <<EOF
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "version":
cmdVersion()
case "status":
cmdStatus()
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "comando desconocido: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println(\`${NOMBRE} — CLI tool
Uso:
${NOMBRE} <comando>
Comandos:
version Imprime la version
status Muestra info del sistema
help Muestra esta ayuda\`)
}
EOF
fi
# ── 4. Makefile, .gitignore, app.md ─────────────────────────
echo "[4/5] Escribiendo Makefile, .gitignore, app.md..."
cat > "$APP_DIR/Makefile" <<EOF
.PHONY: build run install test vet clean
BIN=${NOMBRE}
build:
CGO_ENABLED=1 go build -tags fts5 -o \$(BIN) .
run: build
./\$(BIN) \$(ARGS)
install: build
install -m755 \$(BIN) \$(HOME)/.local/bin/\$(BIN)
@echo "instalado en \$(HOME)/.local/bin/\$(BIN)"
test:
CGO_ENABLED=1 go test -tags fts5 -v ./...
vet:
CGO_ENABLED=1 go vet -tags fts5 ./...
clean:
rm -f \$(BIN)
EOF
cat > "$APP_DIR/.gitignore" <<EOF
# Binario
${NOMBRE}
# IDE
.idea/
.vscode/
*.swp
EOF
if [ "$WITH_TUI" = "true" ]; then
FRAMEWORK="bubbletea"
USES_FUNCTIONS=' - new_base_model_go_tui
- dark_styles_go_tui
- run_fullscreen_go_tui
- new_spinner_go_tui
- new_filtered_list_go_tui'
TAGS='[cli, tui, bubbletea]'
else
FRAMEWORK=""
USES_FUNCTIONS=' []'
TAGS='[cli]'
fi
cat > "$APP_DIR/app.md" <<EOF
---
name: ${NOMBRE}
lang: go
domain: tools
description: "CLI app generada por init_cli_app."
tags: ${TAGS}
uses_functions:
${USES_FUNCTIONS}
uses_types: []
framework: "${FRAMEWORK}"
entry_point: "main.go"
dir_path: "apps/${NOMBRE}"
---
## Notas
CLI con routing de subcomandos (\`os.Args\` + switch). Sin cobra/urfave —
consistente con las apps del registry.
Ejecutar: \`make run ARGS="version"\` o \`./\${NOMBRE} status\`.
EOF
# ── 5. go mod tidy + go vet ─────────────────────────────────
echo "[5/5] Verificacion..."
(
cd "$APP_DIR"
if CGO_ENABLED=1 go mod tidy 2>&1 | tail -5; then
:
fi
if CGO_ENABLED=1 go vet -tags fts5 ./... 2>&1; then
echo " go vet OK"
else
echo " WARN: go vet fallo" >&2
fi
)
echo ""
echo "════════════════════════════════════════════════════════════"
echo " CLI APP '${NOMBRE}' LISTA"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo " cd apps/${NOMBRE}"
echo " make build"
if [ "$WITH_TUI" = "true" ]; then
echo " ./${NOMBRE} # arranca la TUI fullscreen"
echo " ./${NOMBRE} version # comando CLI"
else
echo " ./${NOMBRE} version"
echo " ./${NOMBRE} status"
fi
echo ""
@@ -0,0 +1,118 @@
---
name: init_desktop_app
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_desktop_app(nombre: string, [--with-db]) -> void"
description: "Scaffold de Wails desktop app: Go backend + React frontend con Mantine y @fn_library. Genera main.go (Wails con embed frontend), app.go (bindings Greet/GetVersion), wails.json, go.mod con replace a fn-registry y frontend/ con vite + react + mantine."
tags: [init, scaffold, desktop, wails, pipeline, bash, launcher]
uses_functions:
- assert_command_exists_bash_shell
- scaffold_wails_app_go_infra
- install_wails_bash_infra
- wails_bind_crud_go_infra
- wails_build_go_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: nombre
desc: "nombre de la app Wails (apps/{nombre}/)"
- name: "--with-db"
desc: "anade store.go con SQLite + bindings CRUD (Item, ListItems, CreateItem)"
output: "app Wails en apps/{nombre}/ con main.go + app.go + wails.json + frontend/ Vite+React. Ejecutar 'wails dev' o 'wails build' dentro del directorio."
tested: false
tests: []
test_file_path: ""
example: "fn run init_desktop_app my_tool"
file_path: "bash/functions/pipelines/init_desktop_app.sh"
---
## Sinopsis
```bash
fn run init_desktop_app <nombre> [--with-db]
```
## Ejemplo rapido
```bash
fn run init_desktop_app data_explorer --with-db
cd apps/data_explorer/frontend && pnpm install && cd ..
wails dev # dev con hot reload
# o bien:
wails build && ./build/bin/data_explorer
```
## Archivos generados
| Archivo | Descripcion |
|---------|-------------|
| `main.go` | Entry Wails con embed de frontend/dist, options.App, Bind: []interface{}{app} |
| `app.go` | Struct App con bindings `Greet(name)`, `GetVersion()` (y con `--with-db`: `ListItems`, `CreateItem`) |
| `wails.json` | Config Wails: name, outputfilename, scripts frontend (pnpm) |
| `go.mod` | Modulo Go con `github.com/wailsapp/wails/v2` + replace `fn-registry` |
| `frontend/package.json` | pnpm + vite + react + @mantine/core + @tabler/icons-react |
| `frontend/vite.config.ts` | Alias `@fn_library`, outDir `dist` para embed |
| `frontend/tsconfig.json` | TS strict con paths `@fn_library/*` |
| `frontend/src/main.tsx` | Root con `MantineProvider` (defaultColorScheme: dark) |
| `frontend/src/App.tsx` | Componente que llama bindings `Greet` y `GetVersion` via wailsjs |
| `frontend/postcss.config.cjs` | postcss-preset-mantine |
| `app.md` | Framework: `wails + vite + react + mantine` |
Con `--with-db` anade ademas:
- `store.go``openDB`, tipo `Item`, bindings CRUD `ListItems`, `CreateItem`
## Flags
| Flag | Efecto |
|------|--------|
| `--with-db` | SQLite con schema `items` + bindings CRUD como ejemplo |
## Post-setup
```bash
# 1. Instalar deps del frontend (una vez)
cd apps/{nombre}/frontend && pnpm install && cd ..
# 2. Desarrollo
wails dev # Ventana desktop con hot reload del frontend
# 3. Produccion
wails build # binario en build/bin/{nombre}
./build/bin/{nombre}
```
## Requisitos
El scaffold funciona sin Wails CLI, pero `wails dev`/`wails build` requiere:
- **Wails CLI:** `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- **Deps del sistema (Linux):** GTK3 + WebKit2GTK — usa
`install_wails_bash_infra` para instalarlas:
```bash
source bash/functions/infra/install_wails.sh && install_wails
```
## Notas
Pipeline impuro: genera archivos via heredocs, ejecuta `go mod tidy` al
final como verificacion. Si Wails CLI no esta disponible, reporta el warning
y continua — el scaffold es valido, solo `wails build` falla hasta instalar
el CLI.
El frontend importa `@fn_library` via alias en vite.config.ts apuntando a
`../../../frontend/functions/ui/` (los componentes del registry sin
duplicarlos).
Los bindings de Wails (funciones del struct App) se regeneran en
`frontend/wailsjs/go/main/App.ts` automaticamente cuando corres `wails dev`
o `wails build`.
Abort si `apps/{nombre}/` ya existe.
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI.
+594
View File
@@ -0,0 +1,594 @@
#!/usr/bin/env bash
# init_desktop_app
# ----------------
# Scaffold de Wails desktop app: Go backend + React frontend con @mantine y
# @fn_library. Genera main.go (Wails con embed del frontend), app.go (struct
# App con bindings base), wails.json, go.mod y frontend/ con vite+react+mantine.
#
# USO:
# ./init_desktop_app.sh <nombre> [--with-db]
#
# FLAGS:
# --with-db Anade store.go con SQLite + bindings CRUD de ejemplo
#
# EJEMPLO:
# ./init_desktop_app.sh my_tool
# ./init_desktop_app.sh data_explorer --with-db
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
NOMBRE=""
WITH_DB="false"
SKIP_WAILS_BUILD="${SKIP_WAILS_BUILD:-false}"
while [ $# -gt 0 ]; do
case "$1" in
--with-db) WITH_DB="true"; shift ;;
--skip-wails-build) SKIP_WAILS_BUILD="true"; shift ;;
-h|--help) grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
-*) echo "Flag desconocido: $1" >&2 ; exit 1 ;;
*)
if [ -z "$NOMBRE" ]; then NOMBRE="$1"
else echo "Argumento extra ignorado: $1" >&2
fi
shift ;;
esac
done
if [ -z "$NOMBRE" ]; then
echo "Uso: $0 <nombre> [--with-db]" >&2
exit 1
fi
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
if [ -d "$APP_DIR" ]; then
echo "ERROR: ${APP_DIR} ya existe. Abortando." >&2
exit 1
fi
echo ""
echo "════════════════════════════════════════════════════════════"
echo " INIT DESKTOP APP (Wails): ${NOMBRE}"
echo " Directorio: ${APP_DIR}"
echo " DB: ${WITH_DB}"
echo "════════════════════════════════════════════════════════════"
echo ""
# ── 1. Verificar Go y Wails ──────────────────────────────────
echo "[1/5] Verificando herramientas..."
assert_command_exists go
echo " Go: $(go version)"
if command -v wails >/dev/null 2>&1; then
echo " Wails: $(wails version 2>/dev/null || echo detectado)"
else
echo " WARN: Wails CLI no detectado."
echo " Instalar: source ${REGISTRY_ROOT}/bash/functions/infra/install_wails.sh && install_wails"
echo " (Continuando con scaffold; \`wails build\` requiere el CLI.)"
fi
# ── 2. Crear estructura + archivos Go ────────────────────────
echo "[2/5] Creando estructura y archivos Go..."
mkdir -p "$APP_DIR/frontend/src/pages"
mkdir -p "$APP_DIR/build/bin"
# go.mod con replace a fn-registry
cat > "$APP_DIR/go.mod" <<EOF
module ${NOMBRE}
go 1.25.0
require (
github.com/wailsapp/wails/v2 v2.9.2
)
require (
fn-registry v0.0.0-00010101000000-000000000000
)
replace fn-registry => ${REGISTRY_ROOT}
EOF
# main.go: entry point Wails con embed frontend/dist
cat > "$APP_DIR/main.go" <<'EOF'
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "__APP_NAME__",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}
EOF
sed -i "s/__APP_NAME__/${NOMBRE}/g" "$APP_DIR/main.go"
# app.go: struct App con bindings base
cat > "$APP_DIR/app.go" <<EOF
package main
import (
"context"
"fmt"
"runtime"
)
// App struct — contexto de la app Wails y bindings al frontend.
type App struct {
ctx context.Context
}
// NewApp crea una nueva App.
func NewApp() *App {
return &App{}
}
// startup se llama cuando la app arranca. Persistimos el ctx para usarlo en
// llamadas a runtime.EventsEmit, etc.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet retorna un saludo. Accesible desde el frontend como wails.Greet(name).
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hola, %s!", name)
}
// GetVersion retorna la version de la app y el Go runtime.
func (a *App) GetVersion() map[string]string {
return map[string]string{
"app": "0.1.0",
"name": "${NOMBRE}",
"goVer": runtime.Version(),
"goOS": runtime.GOOS,
}
}
EOF
# wails.json
cat > "$APP_DIR/wails.json" <<EOF
{
"\$schema": "https://wails.io/schemas/config.v2.json",
"name": "${NOMBRE}",
"outputfilename": "${NOMBRE}",
"frontend:install": "pnpm install",
"frontend:build": "pnpm build",
"frontend:dev:watcher": "pnpm dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "",
"email": ""
}
}
EOF
# Store opcional
if [ "$WITH_DB" = "true" ]; then
cat > "$APP_DIR/store.go" <<'EOF'
package main
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// Item es un registro de ejemplo.
type Item struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
}
// openDB abre (o crea) la base de datos SQLite en dbPath e inicializa el schema.
func openDB(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return nil, fmt.Errorf("init schema: %w", err)
}
return db, nil
}
// ListItems devuelve todos los items. Binding accesible desde el frontend.
func (a *App) ListItems() ([]Item, error) {
rows, err := a.db.Query(`SELECT id, name, created_at FROM items ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Item
for rows.Next() {
var it Item
if err := rows.Scan(&it.ID, &it.Name, &it.CreatedAt); err != nil {
return nil, err
}
items = append(items, it)
}
return items, nil
}
// CreateItem inserta un nuevo item.
func (a *App) CreateItem(name string) (Item, error) {
res, err := a.db.Exec(`INSERT INTO items (name) VALUES (?)`, name)
if err != nil {
return Item{}, err
}
id, _ := res.LastInsertId()
return Item{ID: id, Name: name}, nil
}
EOF
# Cuando hay DB, actualizamos app.go para incluir db field
cat > "$APP_DIR/app.go" <<EOF
package main
import (
"context"
"database/sql"
"fmt"
"log"
"runtime"
)
// App struct — contexto de la app Wails, DB y bindings al frontend.
type App struct {
ctx context.Context
db *sql.DB
}
// NewApp crea una nueva App con la DB abierta.
func NewApp() *App {
db, err := openDB("${NOMBRE}.db")
if err != nil {
log.Fatalf("open db: %v", err)
}
return &App{db: db}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hola, %s!", name)
}
func (a *App) GetVersion() map[string]string {
return map[string]string{
"app": "0.1.0",
"name": "${NOMBRE}",
"goVer": runtime.Version(),
"goOS": runtime.GOOS,
}
}
EOF
# Actualizar go.mod para incluir mattn/go-sqlite3
cat > "$APP_DIR/go.mod" <<EOF
module ${NOMBRE}
go 1.25.0
require (
github.com/wailsapp/wails/v2 v2.9.2
github.com/mattn/go-sqlite3 v1.14.37
)
require (
fn-registry v0.0.0-00010101000000-000000000000
)
replace fn-registry => ${REGISTRY_ROOT}
EOF
fi
# ── 3. Frontend ──────────────────────────────────────────────
echo "[3/5] Generando frontend..."
cat > "$APP_DIR/frontend/package.json" <<EOF
{
"name": "${NOMBRE}-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.0.0",
"@mantine/hooks": "^8.0.0",
"@tabler/icons-react": "^3.30.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.7.0",
"vite": "^7.0.0"
}
}
EOF
cat > "$APP_DIR/frontend/vite.config.ts" <<'EOF'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// Vite config para Wails: build → dist/, alias @fn_library a frontend/functions/ui
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});
EOF
cat > "$APP_DIR/frontend/tsconfig.json" <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@fn_library/*": ["../../../frontend/functions/ui/*"]
}
},
"include": ["src"]
}
EOF
cat > "$APP_DIR/frontend/index.html" <<EOF
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${NOMBRE}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
EOF
cat > "$APP_DIR/frontend/postcss.config.cjs" <<'EOF'
module.exports = {
plugins: {
"postcss-preset-mantine": {},
},
};
EOF
cat > "$APP_DIR/frontend/src/theme.ts" <<'EOF'
import { createTheme } from "@mantine/core";
export const theme = createTheme({
primaryColor: "blue",
defaultRadius: "md",
});
EOF
cat > "$APP_DIR/frontend/src/main.tsx" <<'EOF'
import React from "react";
import ReactDOM from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import { theme } from "./theme";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<App />
</MantineProvider>
</React.StrictMode>,
);
EOF
cat > "$APP_DIR/frontend/src/App.tsx" <<EOF
import { useEffect, useState } from "react";
import { Stack, Title, Text, Paper, Button, Group, Badge } from "@mantine/core";
// @ts-expect-error Wails genera este modulo al hacer build
import { Greet, GetVersion } from "../wailsjs/go/main/App";
export function App() {
const [greeting, setGreeting] = useState("");
const [version, setVersion] = useState<Record<string, string> | null>(null);
useEffect(() => {
GetVersion().then(setVersion).catch(() => setVersion(null));
}, []);
return (
<Stack p="xl">
<Title order={2}>${NOMBRE}</Title>
<Text c="dimmed">Desktop app scaffoldeada por init_desktop_app.</Text>
<Paper p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Version</Text>
<Badge color="blue">
{version ? \`\${version.app} (\${version.goOS} \${version.goVer})\` : "cargando..."}
</Badge>
</Group>
</Paper>
<Paper p="md" withBorder>
<Stack>
<Button onClick={() => Greet("desktop").then(setGreeting)}>
Greet from Go
</Button>
{greeting && <Text>{greeting}</Text>}
</Stack>
</Paper>
</Stack>
);
}
EOF
cat > "$APP_DIR/frontend/.gitignore" <<'EOF'
node_modules/
dist/
wailsjs/
*.log
EOF
# .gitignore raiz
cat > "$APP_DIR/.gitignore" <<EOF
# Wails build output
build/bin/
frontend/dist/
frontend/wailsjs/
frontend/node_modules/
# SQLite
*.db
*.db-shm
*.db-wal
# IDE
.idea/
.vscode/
EOF
# ── 4. app.md ────────────────────────────────────────────────
echo "[4/5] Escribiendo app.md..."
USES_FUNCTIONS=' - scaffold_wails_app_go_infra
- install_wails_bash_infra'
cat > "$APP_DIR/app.md" <<EOF
---
name: ${NOMBRE}
lang: go
domain: tools
description: "Desktop app (Wails + React + Mantine) generada por init_desktop_app."
tags: [desktop, wails, frontend]
uses_functions:
${USES_FUNCTIONS}
uses_types: []
framework: "wails + vite + react + mantine"
entry_point: "main.go"
dir_path: "apps/${NOMBRE}"
---
## Notas
Desktop app con backend Go (Wails v2) y frontend React. Bindings en
\`app.go\` son accesibles desde el frontend via \`wails dev\`/\`wails build\`
que genera \`frontend/wailsjs/go/main/App.ts\` automaticamente.
Desarrollo:
\`\`\`bash
cd apps/${NOMBRE}
cd frontend && pnpm install && cd ..
wails dev
\`\`\`
Build:
\`\`\`bash
wails build
./build/bin/${NOMBRE}
\`\`\`
EOF
# ── 5. Verificacion ──────────────────────────────────────────
echo "[5/5] Verificacion..."
if [ "$SKIP_WAILS_BUILD" = "true" ]; then
echo " SKIP_WAILS_BUILD=true — saltando wails build"
elif command -v wails >/dev/null 2>&1; then
echo " wails detectado. Ejecutar manualmente 'wails build' en ${APP_DIR}"
echo " (el build real requiere pnpm install previo en frontend/)"
else
echo " wails no disponible — saltando build."
echo " El scaffold esta listo. Para builds: instalar Wails primero."
fi
# Al menos verificar que go mod tidy funciona (que el main.go/app.go es Go valido)
(
cd "$APP_DIR"
if CGO_ENABLED=1 go mod tidy 2>&1 | tail -5; then
echo " go mod tidy OK"
else
echo " WARN: go mod tidy fallo — revisa main.go/app.go/go.mod" >&2
fi
)
echo ""
echo "════════════════════════════════════════════════════════════"
echo " DESKTOP APP '${NOMBRE}' LISTA"
echo "════════════════════════════════════════════════════════════"
echo ""
echo " Pasos siguientes:"
echo " cd apps/${NOMBRE}/frontend && pnpm install && cd .."
echo " wails dev # modo desarrollo con hot reload"
echo " wails build # binario de produccion en build/bin/"
echo ""
@@ -0,0 +1,49 @@
---
name: init_go_module
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_go_module([module_path: string]) -> void"
description: "Pipeline que inicializa un módulo Go simple en el directorio actual. Crea go.mod (go mod init), main.go con hello world, .gitignore, build.sh (cross-compilation linux/windows/all) y dev.sh para ejecución rápida."
tags: [bash, go, module, init, scaffold, launcher]
uses_functions: [install_go_bash_infra, assert_command_exists_bash_shell]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: module_path
desc: "path del módulo Go, ej: github.com/user/mymodule (default: github.com/user/<dirname>)"
output: "crea archivos en el directorio actual y muestra progreso; exit code 1 si Go no está instalado o go mod init falla"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/init_go_module.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/inicializar_repos/go/init_go_module.sh"
---
## Ejemplo
```bash
mkdir mi-modulo && cd mi-modulo
# Con module_path automático (github.com/user/mi-modulo)
bash bash/functions/pipelines/init_go_module.sh
# Con module_path explícito
bash bash/functions/pipelines/init_go_module.sh github.com/miorg/mi-modulo
# Ejecutar tras crear
./dev.sh
./build.sh
./build.sh all
```
## Notas
Crea un módulo minimalista (main.go simple + build tools). Para proyectos con estructura profesional (cmd/, internal/, pkg/) usar `init_go_project`. No inicializa git — añadido manualmente o via gitea_init_app.
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# init_go_module
# --------------
# Pipeline que inicializa un módulo Go simple en el directorio actual.
# Crea: go.mod, main.go, .gitignore, build.sh y dev.sh.
#
# USO:
# bash init_go_module.sh [module_path]
#
# ARGUMENTOS:
# module_path Path del módulo Go (opcional; default: github.com/user/<dirname>)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
init_go_module() {
local module_path="${1:-}"
local dir_name
dir_name="$(basename "$(pwd)")"
if [[ -z "$module_path" ]]; then
module_path="github.com/user/${dir_name}"
fi
echo "=== Inicializar Módulo Go ==="
echo " Módulo: ${module_path}"
echo " Directorio: $(pwd)"
echo ""
# Verificar Go
if ! command -v go &>/dev/null; then
echo "init_go_module: Go no está instalado" >&2
return 1
fi
echo "Go detectado: $(go version)"
echo ""
# Inicializar módulo
echo "Inicializando go module..."
if ! go mod init "$module_path"; then
echo "init_go_module: falló go mod init" >&2
return 1
fi
echo "go.mod creado"
echo ""
# Crear main.go
echo "Creando main.go..."
cat > main.go << 'GOEOF'
package main
import (
"fmt"
)
func main() {
fmt.Println("Hola desde Go!")
fmt.Println("Modulo inicializado correctamente")
}
GOEOF
echo "main.go creado"
echo ""
# Crear .gitignore
echo "Creando .gitignore..."
cat > .gitignore << 'IGNEOF'
# Binarios
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
build/
# Archivos de test
*.test
*.out
coverage.txt
*.prof
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
IGNEOF
echo ".gitignore creado"
echo ""
# Crear build.sh
echo "Creando build.sh..."
cat > build.sh << 'BUILDEOF'
#!/bin/bash
set -euo pipefail
TARGET="${1:-linux}"
mkdir -p bin
case "$TARGET" in
linux)
GOOS=linux GOARCH=amd64 go build -o bin/app-linux main.go
chmod +x bin/app-linux
echo "Compilado: bin/app-linux"
;;
windows)
GOOS=windows GOARCH=amd64 go build -o bin/app-windows.exe main.go
echo "Compilado: bin/app-windows.exe"
;;
all)
GOOS=linux GOARCH=amd64 go build -o bin/app-linux main.go && echo "Linux OK"
GOOS=windows GOARCH=amd64 go build -o bin/app-windows.exe main.go && echo "Windows OK"
GOOS=darwin GOARCH=amd64 go build -o bin/app-macos main.go && echo "macOS OK"
;;
*)
echo "Uso: ./build.sh [linux|windows|all]" >&2
exit 1
;;
esac
BUILDEOF
chmod +x build.sh
echo "build.sh creado"
echo ""
# Crear dev.sh
echo "Creando dev.sh..."
cat > dev.sh << 'DEVEOF'
#!/bin/bash
set -euo pipefail
go run main.go "$@"
DEVEOF
chmod +x dev.sh
echo "dev.sh creado"
echo ""
echo "=== Modulo Go creado exitosamente ==="
echo ""
echo "Archivos generados:"
echo " main.go - Codigo del modulo"
echo " go.mod - Modulo Go (${module_path})"
echo " .gitignore - Exclusiones git"
echo " build.sh - Compilar binario"
echo " dev.sh - Ejecutar directamente"
echo ""
echo "Proximos pasos:"
echo " ./dev.sh - Ejecutar el modulo"
echo " ./build.sh - Compilar para Linux"
echo " ./build.sh windows - Compilar para Windows"
echo " ./build.sh all - Compilar para todo"
}
init_go_module "$@"
@@ -0,0 +1,49 @@
---
name: init_go_project
kind: pipeline
lang: bash
domain: pipelines
version: "1.0.0"
purity: impure
signature: "init_go_project([module_path: string]) -> void"
description: "Pipeline que inicializa un repositorio Go completo con estructura profesional: cmd/app, internal/config, internal/service (con tests), pkg/version, scripts (run/test/build/build-all/lint), Makefile, .gitignore, README y git init con git add."
tags: [bash, go, project, init, scaffold, professional, launcher]
uses_functions: [install_go_bash_infra, assert_command_exists_bash_shell, create_project_structure_bash_shell]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: module_path
desc: "path del módulo Go, ej: github.com/org/myproject (default: github.com/<whoami>/<dirname>)"
output: "crea estructura completa en el directorio actual y muestra progreso; exit code 1 si Go o git no están disponibles"
tested: false
tests: []
test_file_path: ""
file_path: "bash/functions/pipelines/init_go_project.sh"
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/egutierrez/DevLauncher.git"
source_license: "MIT"
source_file: "scripts/linux/inicializar_repos/go/init_go_proyect.sh"
---
## Ejemplo
```bash
mkdir mi-proyecto && cd mi-proyecto
# Con module_path automático
bash bash/functions/pipelines/init_go_project.sh
# Con module_path explícito
bash bash/functions/pipelines/init_go_project.sh github.com/miorg/mi-proyecto
# Ejecutar y testear tras crear
./scripts/run.sh
./scripts/test.sh
make build-all
```
## Notas
Genera una arquitectura funcional con separación clara: cmd/ (entrypoint), internal/ (lógica privada), pkg/ (librería pública). Incluye un test de ejemplo en internal/service/. Ejecuta go mod tidy y git init + git add al final. Para módulos simples sin estructura, usar `init_go_module`.

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