Compare commits
101 Commits
master
...
5aef738bc8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aef738bc8 | |||
| 126a20ce07 | |||
| f3e62e8303 | |||
| 5965997c9e | |||
| 690e68a542 | |||
| c311623a76 | |||
| b1016ec845 | |||
| cbc4c5eafa | |||
| 89e443ab18 | |||
| f4932ce64c | |||
| 2d108c295a | |||
| 73a4c3a148 | |||
| 356dbcdadd | |||
| 1233efb31d | |||
| 513c2fb4a7 | |||
| 9b5c430f7f | |||
| 5f4f1f7508 | |||
| 9b4bb3aabc | |||
| 34ecadf5a4 | |||
| b55f120a00 | |||
| 89730911c2 | |||
| 3a3a8fd9a9 | |||
| 29b1c4cd8b | |||
| 131f860a94 | |||
| 9660a1c432 | |||
| 256e038cbe | |||
| b406b29074 | |||
| 834e910bcf | |||
| 7605a5760a | |||
| 9f4ac6de32 | |||
| a9f2c60e3d | |||
| 9fd0ca9cac | |||
| 63a9cb5273 | |||
| 25a392df48 | |||
| 9c0d24d3ef | |||
| bee3b0d946 | |||
| af039f6023 | |||
| f168795bda | |||
| bbd2cbff3e | |||
| 056ce6679c | |||
| eb9476503f | |||
| e7a00e221e | |||
| f61a4c4b18 | |||
| 40d6db312d | |||
| c5bb64160f | |||
| e89b78cc45 | |||
| e33b306225 | |||
| 9c859e96d8 | |||
| 10d17f9362 | |||
| 974f704214 | |||
| 0fa16a033c | |||
| f851988d6f | |||
| e9a8cbf20f | |||
| 846012c087 | |||
| 6d0d63cb23 | |||
| b220f8c0be | |||
| 4c52b41b7b | |||
| aea2131dcb | |||
| 1aaeec5090 | |||
| 7e3599e3ac | |||
| 29c8046d4e | |||
| 125ef74358 | |||
| 960f310bcf | |||
| 268a76602a | |||
| dc78d8fea3 | |||
| e02a950ee0 | |||
| a75170cbc6 | |||
| c33e907fef | |||
| d7f2c00d7b | |||
| 8f24157096 | |||
| 3b88857999 | |||
| 4d6ea9a910 | |||
| bb38eedfd1 | |||
| 9d3bfd2cd2 | |||
| 90693fb32f | |||
| b5a6711c64 | |||
| c72ae15429 | |||
| e3bb9c3b38 | |||
| 48caec5665 | |||
| 169cb0853b | |||
| add09c2faa | |||
| f748256c1d | |||
| 4b2240fbce | |||
| c2528c6ea4 | |||
| dd324b7785 | |||
| 9095fe8c65 | |||
| d6240022a4 | |||
| 405be396c8 | |||
| 2c15a0b5e9 | |||
| eaed99e52c | |||
| ac71d4b079 | |||
| f11f60d121 | |||
| e0573302af | |||
| 54be36dd63 | |||
| 72c572e1ea | |||
| 2bae07d1f5 | |||
| 9abaefeb00 | |||
| 05444f74d3 | |||
| 528a16cd5a | |||
| d549aa0314 | |||
| 3798e2d959 |
+178
-11
@@ -10,13 +10,26 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para compos
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Explorar el registry (USAR SIEMPRE)
|
## Explorar el registry (OBLIGATORIO)
|
||||||
|
|
||||||
Antes de escribir codigo, SIEMPRE consulta registry.db para evitar duplicados y descubrir funciones reutilizables.
|
**SIEMPRE** consulta registry.db antes de escribir codigo, crear funciones, o responder sobre el registry. No uses grep/glob sobre archivos .go/.md — la BD es la fuente de verdad.
|
||||||
|
|
||||||
|
**La BD contiene el codigo y la documentacion completa** de cada funcion y tipo en los campos `code`, `documentation` y `notes`. Estos campos tambien estan indexados en FTS5, asi que puedes buscar dentro del codigo y la documentacion directamente. Para leer el codigo de una funcion: `SELECT code FROM functions WHERE id = '...'`. Para leer su documentacion: `SELECT documentation FROM functions WHERE id = '...'`.
|
||||||
|
|
||||||
|
**Busquedas FTS5 obligatorias:** Usa SIEMPRE la tabla FTS5 para buscar tanto por `name` como por `description`. Esto encuentra coincidencias parciales y similares que una busqueda exacta perderia. Usa operadores FTS5: `OR` para ampliar, `*` para prefijos, `NEAR` para proximidad.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# FTS5
|
# Busqueda FTS5 por nombre Y descripcion (USAR SIEMPRE ESTE PATRON)
|
||||||
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'slice') ORDER BY name;"
|
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slice OR description:slice') ORDER BY name;"
|
||||||
|
|
||||||
|
# FTS5 con prefijo (encuentra slice, slicing, sliced...)
|
||||||
|
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:slic* OR description:slic*') ORDER BY name;"
|
||||||
|
|
||||||
|
# FTS5 en tipos
|
||||||
|
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH 'name:result OR description:result') ORDER BY name;"
|
||||||
|
|
||||||
|
# FTS5 por semantica de params (composabilidad)
|
||||||
|
sqlite3 registry.db "SELECT id, json_extract(params_schema, '$.output') FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'params_schema:retornos');"
|
||||||
|
|
||||||
# Por dominio
|
# Por dominio
|
||||||
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
|
sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain = 'finance' ORDER BY name;"
|
||||||
@@ -24,7 +37,7 @@ sqlite3 registry.db "SELECT id, purity, signature FROM functions WHERE domain =
|
|||||||
# Puras de un dominio
|
# Puras de un dominio
|
||||||
sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;"
|
sqlite3 registry.db "SELECT id, signature FROM functions WHERE domain = 'core' AND purity = 'pure' ORDER BY name;"
|
||||||
|
|
||||||
# Tipos
|
# Tipos por dominio
|
||||||
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';"
|
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE domain = 'cybersecurity';"
|
||||||
|
|
||||||
# Dependencias
|
# Dependencias
|
||||||
@@ -37,19 +50,46 @@ sqlite3 registry.db "SELECT id, kind, status, title FROM proposals WHERE status
|
|||||||
sqlite3 registry.db ".schema"
|
sqlite3 registry.db ".schema"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Regla:** Si necesitas saber si algo existe o hay algo similar, haz la consulta FTS5 sobre la BD. No asumas que no existe sin consultar primero.
|
||||||
|
|
||||||
|
### Schema rapido
|
||||||
|
|
||||||
|
**functions** — columnas: `id, name, kind, lang, domain, version, purity, signature, description, tags, uses_functions, uses_types, returns, returns_optional, error_type, imports, example, tested, tests, test_file_path, file_path, created_at, updated_at, props, emits, has_state, framework, variant, notes, documentation, code, content_hash, source_repo, source_license, source_file, params_schema`
|
||||||
|
- `params_schema`: JSON con semántica de inputs/outputs. Formato: `{"params":[{"name":"x","desc":"..."}],"output":"..."}`. Buscable via FTS5.
|
||||||
|
- Enums: `kind`(function|pipeline|component) `purity`(pure|impure) `lang`(go|py|bash|ps)
|
||||||
|
- Dominios: core, infra, finance, datascience, cybersecurity, shell, tui, pipelines, browser
|
||||||
|
|
||||||
|
**types** — columnas: `id, name, lang, domain, version, algebraic, definition, description, tags, uses_types, file_path, created_at, updated_at, examples, notes, documentation, code, content_hash, source_repo, source_license, source_file`
|
||||||
|
- Enums: `algebraic`(product|sum)
|
||||||
|
|
||||||
|
**unit_tests** — columnas: `id, function_id, name, code, file_path, lang, created_at, updated_at`
|
||||||
|
- Extraidos automaticamente por `fn index` desde los archivos de test
|
||||||
|
- FK: `function_id` → `functions.id`
|
||||||
|
|
||||||
|
**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
|
||||||
|
- `unit_tests_fts`: id, name, code, function_id, lang
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Estructura
|
## Estructura
|
||||||
|
|
||||||
```
|
```
|
||||||
fn-registry/
|
fn-registry/
|
||||||
functions/{domain}/ # .go + .md por funcion (core, finance, datascience, cybersecurity)
|
functions/{domain}/ # .go + .md por funcion Y tipo Go (core, finance, datascience, cybersecurity)
|
||||||
functions/pipelines/ # Composiciones, siempre impuras
|
functions/pipelines/ # Composiciones, siempre impuras
|
||||||
functions/components/ # React (.tsx)
|
types/{domain}/ # Solo .md de tipos (los .go viven en functions/{domain}/)
|
||||||
types/{domain}/ # .go + .md por tipo
|
python/functions/ # .py + .md por funcion Python
|
||||||
|
python/types/ # .py + .md por tipo Python
|
||||||
|
bash/functions/ # .sh + .md por funcion Bash (core, infra, io, shell)
|
||||||
|
frontend/ # pnpm + vite + react + mantine
|
||||||
|
frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React)
|
||||||
|
frontend/types/ # .ts + .md por tipo
|
||||||
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
|
registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones
|
||||||
fn_operations/ # Paquete Go: operations database (libreria)
|
fn_operations/ # Paquete Go: operations database (libreria)
|
||||||
apps/ # Apps ejecutables (TUIs, CLIs) — modulos Go independientes, cada una con su operations.db
|
apps/ # Apps ejecutables (TUIs, CLIs, scripts) — codigo NO reutilizable, cada una con su operations.db
|
||||||
|
analysis/ # Exploraciones Jupyter independientes — cada una con su venv, MCP y kernel conectado al registry
|
||||||
cmd/fn/ # CLI principal
|
cmd/fn/ # CLI principal
|
||||||
docs/ # Specs de diseño
|
docs/ # Specs de diseño
|
||||||
docs/templates/ # Plantillas de frontmatter
|
docs/templates/ # Plantillas de frontmatter
|
||||||
@@ -76,6 +116,24 @@ fn search -k function -p pure -d core "slice"
|
|||||||
fn list [-d domain] [-k kind]
|
fn list [-d domain] [-k kind]
|
||||||
fn show <id>
|
fn show <id>
|
||||||
fn add -k function # Template
|
fn add -k function # Template
|
||||||
|
fn check params # Lista funciones sin params_schema
|
||||||
|
|
||||||
|
# Ejecutar funciones y pipelines (fn run)
|
||||||
|
fn run <id_or_name> [args...] # Ejecuta por ID o nombre
|
||||||
|
fn run init_metabase --project test # Go pipeline (go run .)
|
||||||
|
fn run setup_metabase_volume # Bash pipeline (bash <file>)
|
||||||
|
fn run metabase_setup_py_infra # Python (python/.venv/bin/python3 <file>)
|
||||||
|
fn run my_component_ts_core # TypeScript (frontend/node_modules/.bin/tsx <file>)
|
||||||
|
fn run filter_slice_go_core # Go function con tests (go test -v)
|
||||||
|
fn run docker_pull_image_go_infra # Go function sin tests (go vet)
|
||||||
|
# Despacho por lenguaje:
|
||||||
|
# go (con main.go en dir) → go run .
|
||||||
|
# go (con tests) → go test -v -count=1 -tags fts5 ./pkg/
|
||||||
|
# go (sin tests) → go vet -tags fts5 ./pkg/
|
||||||
|
# py → python/.venv/bin/python3 <file>
|
||||||
|
# bash → bash <file>
|
||||||
|
# ts → frontend/node_modules/.bin/tsx <file>
|
||||||
|
# Si el nombre es ambiguo, muestra los IDs para desambiguar.
|
||||||
|
|
||||||
# Proposals
|
# Proposals
|
||||||
fn proposal add --kind new_function --title "..." --created-by agent [--target-id <id>]
|
fn proposal add --kind new_function --title "..." --created-by agent [--target-id <id>]
|
||||||
@@ -96,16 +154,41 @@ fn ops assertion result add|list
|
|||||||
|
|
||||||
`FN_REGISTRY_ROOT` env var permite que `fn ops` acceda a registry.db desde cualquier directorio.
|
`FN_REGISTRY_ROOT` env var permite que `fn ops` acceda a registry.db desde cualquier directorio.
|
||||||
|
|
||||||
|
### Uso de fn run por agentes
|
||||||
|
|
||||||
|
`fn run` permite ejecutar directamente funciones y pipelines del registry desde la terminal. Usar para:
|
||||||
|
- Lanzar pipelines con sus argumentos: `./fn run init_metabase --project fn_registry`
|
||||||
|
- Correr tests de funciones Go: `./fn run filter_slice_go_core`
|
||||||
|
- Ejecutar scripts Python/Bash del registry sin montar paths manualmente
|
||||||
|
- Verificar que funciones Go compilan correctamente (go vet)
|
||||||
|
|
||||||
|
Entornos usados automaticamente:
|
||||||
|
- Python: `python/.venv/bin/python3` (venv del proyecto)
|
||||||
|
- TypeScript: `frontend/node_modules/.bin/tsx` (node del proyecto)
|
||||||
|
- Go: `go run .` / `go test` / `go vet` con `CGO_ENABLED=1 -tags fts5`
|
||||||
|
- Bash: `bash` del sistema
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Añadir funciones
|
## Añadir funciones
|
||||||
|
|
||||||
1. Consulta la BD para verificar que no existe algo similar
|
1. Consulta la BD para verificar que no existe algo similar
|
||||||
2. Crea dos archivos: `functions/{domain}/{name}.go` + `functions/{domain}/{name}.md`
|
2. Crea dos archivos segun el lenguaje:
|
||||||
|
- Go: `functions/{domain}/{name}.go` + `.md`
|
||||||
|
- Python: `python/functions/{domain}/{name}.py` + `.md`
|
||||||
|
- Bash: `bash/functions/{domain}/{name}.sh` + `.md`
|
||||||
|
- TypeScript: `frontend/functions/{domain}/{name}.ts` + `.md`
|
||||||
3. Ejecuta `./fn index` y verifica con `./fn show {id}`
|
3. Ejecuta `./fn index` y verifica con `./fn show {id}`
|
||||||
|
|
||||||
Frontmatter del .md — ver template completo en `docs/templates/` o con `fn add -k function`.
|
Frontmatter del .md — ver template completo en `docs/templates/` o con `fn add -k function`.
|
||||||
|
|
||||||
|
Campos `params` y `output` (obligatorios en frontmatter):
|
||||||
|
- `params`: lista de `{name, desc}` con descripción semántica de cada parámetro (qué representa, unidades, rango)
|
||||||
|
- `output`: descripción semántica de lo que retorna la función
|
||||||
|
- Para componentes: solo `output` (ya tienen `props`)
|
||||||
|
- Se indexan como JSON en `params_schema` y son buscables via FTS5
|
||||||
|
- `fn check params` lista funciones sin documentar
|
||||||
|
|
||||||
Reglas de integridad (el indexer las valida):
|
Reglas de integridad (el indexer las valida):
|
||||||
- Pipeline → siempre impuro + uses_functions no vacio
|
- Pipeline → siempre impuro + uses_functions no vacio
|
||||||
- Pure → returns_optional: false + error_type: ""
|
- Pure → returns_optional: false + error_type: ""
|
||||||
@@ -118,7 +201,91 @@ Reglas de integridad (el indexer las valida):
|
|||||||
|
|
||||||
## Añadir tipos
|
## Añadir tipos
|
||||||
|
|
||||||
Dos archivos: `types/{domain}/{name}.go` + `types/{domain}/{name}.md`. Ver template en `docs/templates/`.
|
Dos archivos en directorios separados:
|
||||||
|
- **Codigo Go:** `functions/{domain}/{name}.go` (junto a las funciones, mismo paquete Go)
|
||||||
|
- **Metadata .md:** `types/{domain}/{name}.md` con `file_path` apuntando a `functions/{domain}/{name}.go`
|
||||||
|
|
||||||
|
Los `.go` de tipos viven en `functions/{domain}/` para que Go los compile en el mismo paquete que las funciones que los usan. Los `.md` se mantienen en `types/{domain}/` para que el indexer los identifique como tipos.
|
||||||
|
|
||||||
|
Ver template en `docs/templates/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analysis (exploraciones Jupyter)
|
||||||
|
|
||||||
|
Carpeta `analysis/` para exploraciones de datos con Jupyter + agentes Claude. Mismo patron que `apps/` — cada analisis es independiente con su propio venv, MCP y kernel.
|
||||||
|
|
||||||
|
**NO es codigo reutilizable** — son investigaciones ad-hoc. Si algo de un analisis resulta util, se extrae como funcion al registry.
|
||||||
|
|
||||||
|
### Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
analysis/
|
||||||
|
{tema}/ # Cada analisis es autonomo
|
||||||
|
.venv/ # Deps propias (gitignored)
|
||||||
|
.mcp.json # MCP jupyter apuntando a SU venv (gitignored)
|
||||||
|
.claude/CLAUDE.md # Reglas para agentes en este analisis
|
||||||
|
.ipython/profile_default/startup/ # Kernel startup con acceso al registry
|
||||||
|
00_fn_registry.py # Autocarga FN_REGISTRY_ROOT, helpers, sys.path
|
||||||
|
notebooks/ # Notebooks de exploracion
|
||||||
|
data/ # Datos locales (gitignored)
|
||||||
|
run-jupyter-lab.sh # Launcher Jupyter colaborativo
|
||||||
|
pyproject.toml # Deps gestionadas con uv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crear un analisis nuevo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basico
|
||||||
|
fn run init_jupyter_analysis finanzas
|
||||||
|
|
||||||
|
# Con paquetes extra
|
||||||
|
fn run init_jupyter_analysis ml scikit-learn torch
|
||||||
|
fn run init_jupyter_analysis duckdb polars duckdb
|
||||||
|
```
|
||||||
|
|
||||||
|
El pipeline `init_jupyter_analysis_bash_pipelines` compone 8 funciones atomicas del registry.
|
||||||
|
|
||||||
|
### Usar un analisis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: lanzar Jupyter
|
||||||
|
cd analysis/{tema} && ./run-jupyter-lab.sh
|
||||||
|
|
||||||
|
# Terminal 2: abrir Claude con MCP jupyter
|
||||||
|
cd analysis/{tema} && claude
|
||||||
|
|
||||||
|
# Navegador: http://localhost:8888
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceso al registry desde notebooks
|
||||||
|
|
||||||
|
El kernel startup (`00_fn_registry.py`) se ejecuta automaticamente al abrir cualquier notebook y provee:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Helpers disponibles sin importar nada:
|
||||||
|
fn_search("slice") # Busca funciones y tipos por nombre/descripcion
|
||||||
|
fn_query("SELECT ...") # SQL directo sobre registry.db
|
||||||
|
fn_code("filter_list_py_core") # Codigo fuente de una funcion
|
||||||
|
|
||||||
|
# Importar funciones Python del registry directamente:
|
||||||
|
from core import filter_list, map_list, reduce_list
|
||||||
|
from finance import sma, ema, rsi
|
||||||
|
from metabase import MetabaseClient
|
||||||
|
|
||||||
|
# Variable de entorno disponible:
|
||||||
|
import os
|
||||||
|
os.environ["FN_REGISTRY_ROOT"] # Raiz del registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas para agentes en analysis
|
||||||
|
|
||||||
|
Cada analisis tiene su `.claude/CLAUDE.md` con reglas especificas:
|
||||||
|
- Celdas inmutables: nunca modificar celdas existentes, solo anadir nuevas
|
||||||
|
- Programacion funcional obligatoria: funciones puras, sin mutacion
|
||||||
|
- Usar MCP jupyter para ejecutar codigo, nunca bash
|
||||||
|
- Notebooks en `notebooks/`, maximo 50 celdas por notebook
|
||||||
|
- Dependencias con `uv add`, nunca pip directo
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# /extract-source — Extraer funciones de un repo en sources/
|
||||||
|
|
||||||
|
Eres un agente extractor de funciones. Tu trabajo es analizar un repositorio clonado en `sources/` y extraer funciones reutilizables al registry siguiendo las reglas de `.claude/rules/sources.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Argumento
|
||||||
|
|
||||||
|
`$ARGUMENTS` — nombre del directorio en `sources/` (ej: `MiroFish`, `OpenViking`). Si no se proporciona, listar los directorios disponibles en `sources/` y pedir al usuario que elija.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 0: Validar el source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls sources/$ARGUMENTS/
|
||||||
|
```
|
||||||
|
|
||||||
|
Si no existe, abortar. Verificar que tenga licencia compatible (MIT, Apache 2.0, BSD, ISC, MPL-2.0, Unlicense). Si es AGPL, GPL, o no tiene licencia, **advertir al usuario** y pedir confirmacion antes de continuar.
|
||||||
|
|
||||||
|
Identificar:
|
||||||
|
- **Licencia**: leer LICENSE/LICENSE.md/COPYING
|
||||||
|
- **Lenguaje principal**: detectar por archivos (*.go, *.py, *.rs, *.ts, *.js, Cargo.toml, go.mod, pyproject.toml, package.json)
|
||||||
|
- **URL del repo**: buscar en README, .git/config, o package.json
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 1: Revisar el manifest
|
||||||
|
|
||||||
|
Leer `sources/sources.yaml` para ver si este repo ya tiene extracciones previas. Si las tiene, listarlas al usuario y preguntar si quiere continuar extrayendo mas o si quiere re-evaluar las existentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 2: Explorar el repositorio
|
||||||
|
|
||||||
|
Analizar la estructura del repo para identificar **todas las funciones candidatas** — puras e impuras. El objetivo es maximizar la extraccion de codigo util.
|
||||||
|
|
||||||
|
### Que buscar (por categoria)
|
||||||
|
|
||||||
|
**A. Funciones puras** (algoritmos, transformaciones, calculos, validaciones):
|
||||||
|
- Parsers, encoders/decoders, formatters
|
||||||
|
- Algoritmos matematicos, estadisticos, financieros
|
||||||
|
- Transformaciones de datos, filtros, mappers
|
||||||
|
- Validaciones, sanitizaciones
|
||||||
|
|
||||||
|
**B. Funciones impuras** (I/O, red, estado externo):
|
||||||
|
- Clientes HTTP/API (REST, GraphQL, WebSocket)
|
||||||
|
- Operaciones de filesystem (leer, escribir, monitorear archivos)
|
||||||
|
- Interacciones con bases de datos (queries, migraciones)
|
||||||
|
- Operaciones Docker, cloud, infraestructura
|
||||||
|
- Scraping, crawling, recoleccion de datos
|
||||||
|
- Notificaciones, envio de mensajes
|
||||||
|
|
||||||
|
**C. Pipelines** (composiciones multi-paso):
|
||||||
|
- Flujos ETL (extract-transform-load)
|
||||||
|
- Workflows de setup/deploy/provision
|
||||||
|
- Secuencias de procesamiento de datos
|
||||||
|
- Orquestaciones que componen varias funciones
|
||||||
|
|
||||||
|
**D. Tipos reutilizables** (structs, enums, interfaces):
|
||||||
|
- Modelos de dominio genericos
|
||||||
|
- Tipos de configuracion
|
||||||
|
- Interfaces/protocolos bien definidos
|
||||||
|
|
||||||
|
### Estrategia de exploracion segun lenguaje
|
||||||
|
- **Go**: `pkg/`, `internal/`, `utils/`, `lib/`, `cmd/` — funciones exportadas, handlers, clients
|
||||||
|
- **Python**: `src/`, `lib/`, `utils/`, `core/`, `api/` — funciones, clases client, decoradores
|
||||||
|
- **Rust**: `crates/`, `src/lib.rs` — funciones pub, traits implementados
|
||||||
|
- **TypeScript/JS**: `src/`, `lib/`, `utils/`, `services/` — funciones, hooks, componentes
|
||||||
|
- **Bash**: `scripts/`, `bin/`, `tools/` — funciones con firma clara
|
||||||
|
|
||||||
|
### Que ignorar
|
||||||
|
- main(), CLI entry points (pero extraer las funciones que invocan)
|
||||||
|
- Tests (pero notar cuales funciones estan bien testeadas — marcar `tested: true`)
|
||||||
|
- Funciones que dependen de tipos internos complejos **no adaptables**
|
||||||
|
- Codigo con dependencias externas pesadas que no esten en fn_registry
|
||||||
|
- Config loaders hardcodeados a un proyecto especifico
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 3: Consultar el registry para evitar duplicados
|
||||||
|
|
||||||
|
Antes de proponer cualquier funcion, buscar en registry.db con FTS5:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Por cada candidata, buscar similares
|
||||||
|
sqlite3 registry.db "SELECT id, kind, purity, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:NOMBRE* OR description:DESCRIPCION') ORDER BY name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ya existe algo similar, descartarla o anotar que es una mejora/variante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 4: Presentar candidatas al usuario
|
||||||
|
|
||||||
|
Agrupar las candidatas por categoria y mostrar en tablas separadas:
|
||||||
|
|
||||||
|
### Funciones puras
|
||||||
|
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Descripcion |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
|
||||||
|
### Funciones impuras
|
||||||
|
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | I/O tipo | Descripcion |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
|
(I/O tipo: HTTP, filesystem, DB, Docker, network, etc.)
|
||||||
|
|
||||||
|
### Pipelines (composiciones)
|
||||||
|
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Funciones que compone | Descripcion |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
|
### Tipos
|
||||||
|
| # | Nombre propuesto | Origen (archivo) | Lang destino | Dominio | Algebraic | Descripcion |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
|
Para cada candidata indicar:
|
||||||
|
- Por que cumple el filtro de calidad
|
||||||
|
- Si requiere adaptacion (renombrar tipos, quitar dependencias, traducir lenguaje)
|
||||||
|
- Si es traduccion de otro lenguaje (ej: Rust → Go)
|
||||||
|
- Para impuras: cual es el `error_type` apropiado
|
||||||
|
|
||||||
|
**Esperar confirmacion del usuario** antes de extraer. El usuario puede:
|
||||||
|
- Aprobar todas (`all`)
|
||||||
|
- Seleccionar por numero (`1,3,5-8`)
|
||||||
|
- Seleccionar por categoria (`todas las puras`, `solo pipelines`)
|
||||||
|
- Pedir explorar mas areas del repo
|
||||||
|
- Descartar y terminar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 5: Extraer funciones aprobadas
|
||||||
|
|
||||||
|
Para cada funcion aprobada:
|
||||||
|
|
||||||
|
### 5a. Determinar destino y clasificacion
|
||||||
|
|
||||||
|
| Naturaleza | Destino | kind | purity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Algoritmo/logica pura | Go/Python `functions/{domain}/` | function | pure |
|
||||||
|
| Funcion con I/O (HTTP, DB, fs) | Go/Python `functions/{domain}/` | function | impure |
|
||||||
|
| Script/utilidad sistema | Bash `bash/functions/{domain}/` | function | impure |
|
||||||
|
| UI/componente | TypeScript `frontend/functions/{domain}/` | component | — |
|
||||||
|
| Composicion multi-paso | `functions/pipelines/` o `python/functions/pipelines/` | pipeline | impure |
|
||||||
|
| C/Rust/otro lenguaje | Traducir a Go o Python manteniendo semantica | segun caso | segun caso |
|
||||||
|
|
||||||
|
### 5b. Crear archivos
|
||||||
|
|
||||||
|
1. **Codigo** — copiar y adaptar:
|
||||||
|
- Renombrar a snake_case
|
||||||
|
- Usar tipos nativos en firma (no tipos internos del repo)
|
||||||
|
- Quitar dependencias externas, usar stdlib
|
||||||
|
- Ajustar al paquete Go destino (nombre = nombre del directorio)
|
||||||
|
- Si es traduccion, mantener la semantica y documentar el origen
|
||||||
|
|
||||||
|
2. **Metadata .md** — crear frontmatter completo:
|
||||||
|
- `source_repo`: URL del repo original
|
||||||
|
- `source_license`: licencia del repo
|
||||||
|
- `source_file`: path relativo del archivo original dentro del repo
|
||||||
|
- Todos los campos obligatorios segun el tipo (function/pipeline/component)
|
||||||
|
- Reglas de pureza:
|
||||||
|
- `pure` → `returns_optional: false` + `error_type: ""`
|
||||||
|
- `impure` → `error_type: "error_go_core"` (o equivalente Python)
|
||||||
|
- `pipeline` → `purity: impure` + `uses_functions` con las funciones que compone
|
||||||
|
|
||||||
|
### 5c. Verificar integridad
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Indexar
|
||||||
|
./fn index
|
||||||
|
|
||||||
|
# Verificar cada funcion extraida
|
||||||
|
./fn show {id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el indexer reporta errores, corregir antes de continuar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 6: Actualizar manifest
|
||||||
|
|
||||||
|
Anadir las funciones extraidas a `sources/sources.yaml` bajo el repo correspondiente:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- repo: https://github.com/user/project
|
||||||
|
license: MIT
|
||||||
|
cloned_dir: nombre_directorio
|
||||||
|
extracted:
|
||||||
|
- id: funcion_go_core
|
||||||
|
source_file: pkg/utils.go
|
||||||
|
date: YYYY-MM-DD # fecha de hoy
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el repo no existe en el manifest, crear la entrada completa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 7: Resumen
|
||||||
|
|
||||||
|
Mostrar al usuario:
|
||||||
|
- Funciones extraidas exitosamente (con IDs)
|
||||||
|
- Funciones descartadas y por que
|
||||||
|
- Warnings del indexer si hubo
|
||||||
|
- Sugerencia de areas del repo que podrian explorarse en el futuro
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas criticas
|
||||||
|
|
||||||
|
- **NUNCA extraer sin aprobacion del usuario** — siempre presentar candidatas primero
|
||||||
|
- **NUNCA ignorar el filtro de calidad** — si no cumple todos los criterios, no se extrae
|
||||||
|
- **SIEMPRE consultar registry.db** antes de proponer — evitar duplicados
|
||||||
|
- **SIEMPRE atribuir** — source_repo, source_license, source_file en el .md
|
||||||
|
- **SIEMPRE actualizar sources.yaml** — es el manifest versionado
|
||||||
|
- **Licencias no permisivas** (GPL, AGPL) requieren advertencia explicita al usuario
|
||||||
|
- **Traduccion de lenguaje** es valida — documentar el origen claramente
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
# /frontend — Skill para proyectos frontend
|
||||||
|
|
||||||
|
Eres un arquitecto frontend experto. Esta skill se activa cuando el usuario pide crear un proyecto frontend, una app con UI, un componente nuevo, o una feature frontend. Tu trabajo es garantizar que TODO el frontend se construya usando el sistema de funciones reutilizables del registry y las mejores practicas actuales.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **pnpm** — gestor de paquetes
|
||||||
|
- **React 19** — UI library
|
||||||
|
- **Vite 8** — build tool
|
||||||
|
- **Mantine v9** — component library + styling (props, no CSS manual)
|
||||||
|
- **Phosphor Icons** — `@phosphor-icons/react`
|
||||||
|
- **Recharts** — charts (via `@mantine/charts`)
|
||||||
|
|
||||||
|
**NO usar:** Tailwind, shadcn, CVA, clsx, cn(), lucide-react, styled-components, emotion, CSS-in-JS runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 1: Consultar el registry (OBLIGATORIO)
|
||||||
|
|
||||||
|
Antes de escribir una sola linea de codigo, consulta registry.db para saber que componentes, funciones y tipos frontend ya existen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Componentes y funciones frontend disponibles
|
||||||
|
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE lang IN ('ts','typescript') ORDER BY domain, name;"
|
||||||
|
|
||||||
|
# Tipos frontend disponibles
|
||||||
|
sqlite3 registry.db "SELECT id, algebraic, description FROM types WHERE lang IN ('ts','typescript') ORDER BY domain, name;"
|
||||||
|
|
||||||
|
# Busqueda FTS5 si buscas algo especifico
|
||||||
|
sqlite3 registry.db "SELECT id, kind, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'name:chart* OR description:chart*') ORDER BY name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tambien lista los archivos reales en disco ya que no todos estan indexados aun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls frontend/functions/ui/ # Componentes React
|
||||||
|
ls frontend/functions/core/ # Utilidades TS puras
|
||||||
|
ls frontend/types/ # Tipos
|
||||||
|
```
|
||||||
|
|
||||||
|
**REGLA:** Si un componente ya existe en `frontend/functions/ui/` (alias `@fn_library`), USALO. Nunca recrear lo que ya existe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PASO 2: Determinar el tipo de trabajo
|
||||||
|
|
||||||
|
### A) App nueva en `apps/`
|
||||||
|
Ir a → Seccion SCAFFOLD APP
|
||||||
|
|
||||||
|
### B) Componente nuevo para el registry
|
||||||
|
Ir a → Seccion CREAR COMPONENTE
|
||||||
|
|
||||||
|
### C) Feature en app existente
|
||||||
|
Ir a → Seccion CREAR FEATURE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SCAFFOLD APP
|
||||||
|
|
||||||
|
Crear la estructura completa de una app frontend nueva en `apps/{nombre}/frontend/`.
|
||||||
|
|
||||||
|
### Estructura obligatoria
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/{nombre}/
|
||||||
|
frontend/
|
||||||
|
package.json
|
||||||
|
vite.config.ts
|
||||||
|
tsconfig.json
|
||||||
|
postcss.config.cjs
|
||||||
|
index.html
|
||||||
|
src/
|
||||||
|
main.tsx # Entry point con MantineProvider
|
||||||
|
App.tsx # Root con Router
|
||||||
|
app.css # Minimal (font-smoothing solo)
|
||||||
|
features/ # Feature-based co-location
|
||||||
|
{feature}/
|
||||||
|
components/ # Componentes del feature
|
||||||
|
hooks/ # Hooks del feature
|
||||||
|
types.ts # Tipos del feature
|
||||||
|
index.ts # Barrel export publico
|
||||||
|
components/ # Componentes compartidos de esta app (no reutilizables)
|
||||||
|
hooks/ # Hooks compartidos
|
||||||
|
lib/ # Utilidades, API client
|
||||||
|
types/ # Tipos globales de la app
|
||||||
|
```
|
||||||
|
|
||||||
|
### package.json base
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "{nombre}",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^9.0.0",
|
||||||
|
"@mantine/hooks": "^9.0.0",
|
||||||
|
"@mantine/notifications": "^9.0.0",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Agregar dependencias extras segun necesidad:
|
||||||
|
- **Charts**: `@mantine/charts`, `recharts`
|
||||||
|
- **Tablas**: `@tanstack/react-table`
|
||||||
|
- **Forms**: `react-hook-form`, `@hookform/resolvers`, `zod`
|
||||||
|
- **Dates**: `@mantine/dates`, `dayjs`
|
||||||
|
- **Router**: `react-router` o `@tanstack/react-router`
|
||||||
|
- **State**: `zustand` (client state), `@tanstack/react-query` (server state)
|
||||||
|
- **Wails**: los hooks de Wails ya estan en `@fn_library` (useWailsQuery, useWailsMutation, useWailsStream, useWailsEvent, WailsProvider)
|
||||||
|
|
||||||
|
### vite.config.ts base
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
|
||||||
|
},
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
postcss: resolve(__dirname, './postcss.config.cjs'),
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'react-vendor': ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### postcss.config.cjs base
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.css base
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Minimal — Mantine handles all theming via MantineProvider */
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### main.tsx base
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
import './app.css'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { MantineProvider, createTheme } from '@mantine/core'
|
||||||
|
import { Notifications } from '@mantine/notifications'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: 'blue',
|
||||||
|
defaultRadius: 'md',
|
||||||
|
// Customize colors, fonts, etc. here
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||||
|
<Notifications />
|
||||||
|
<App />
|
||||||
|
</MantineProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Despues del scaffold
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/{nombre}/frontend && pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CREAR COMPONENTE
|
||||||
|
|
||||||
|
Para componentes nuevos que van al registry en `frontend/functions/`.
|
||||||
|
|
||||||
|
### Reglas de implementacion
|
||||||
|
|
||||||
|
1. **Mantine first**: wrappear componentes de Mantine. Solo crear desde cero si Mantine no tiene equivalente.
|
||||||
|
2. **Styling via props**: usar props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.) y el style system. NUNCA clases CSS manuales ni Tailwind.
|
||||||
|
3. **CSS variables de Mantine**: si necesitas styles inline, usar `var(--mantine-color-*)`, `var(--mantine-spacing-*)`, etc.
|
||||||
|
4. **Iconos**: usar `@phosphor-icons/react`, no lucide-react ni @tabler/icons-react.
|
||||||
|
5. **Props tipadas**: usar `React.ComponentPropsWithoutRef<"element">` para HTML props spreading.
|
||||||
|
6. **Accesibilidad**:
|
||||||
|
- Elementos semanticos: `<button>` para acciones, `<a>` para navegacion
|
||||||
|
- NUNCA `<div onClick>` para elementos interactivos
|
||||||
|
- `aria-label` en botones de solo icono
|
||||||
|
- `aria-invalid` + `aria-describedby` en inputs con error
|
||||||
|
- Focus management en modales/popovers
|
||||||
|
7. **Discriminated unions** cuando las props cambian segun variante:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type Props = { size?: 'sm' | 'md' | 'lg'; children: React.ReactNode } & (
|
||||||
|
| { variant: 'link'; href: string; onClick?: never }
|
||||||
|
| { variant: 'button'; onClick: () => void; href?: never }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron de archivo .tsx
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Select, type SelectProps } from '@mantine/core'
|
||||||
|
|
||||||
|
// Re-export con defaults o logica adicional si necesario
|
||||||
|
interface MySelectProps extends Omit<SelectProps, 'xxx'> {
|
||||||
|
customProp?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function MySelect({ customProp, ...props }: MySelectProps) {
|
||||||
|
return <Select {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MySelect }
|
||||||
|
export type { MySelectProps }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron de archivo .md
|
||||||
|
|
||||||
|
**IMPORTANTE:** El campo `lang` debe ser `ts` (no `typescript`). El indexer solo reconoce `ts`. Los IDs siguen el formato `{name}_ts_{domain}`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: component_name
|
||||||
|
kind: component
|
||||||
|
lang: ts
|
||||||
|
domain: ui
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "ComponentName(props: ComponentProps): JSX.Element"
|
||||||
|
description: "Descripcion concisa de que hace el componente"
|
||||||
|
tags: [component, ui, ...]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: ["@mantine/core"]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "frontend/functions/ui/component_name.tsx"
|
||||||
|
props:
|
||||||
|
- name: variant
|
||||||
|
type: "'default' | 'secondary'"
|
||||||
|
required: false
|
||||||
|
description: "Estilo visual"
|
||||||
|
emits: []
|
||||||
|
has_state: false
|
||||||
|
framework: react
|
||||||
|
variant: [default]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
...codigo de ejemplo...
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
...notas relevantes...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Despues de crear
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn index && ./fn show {id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CREAR FEATURE
|
||||||
|
|
||||||
|
Para features dentro de una app existente. Co-location obligatoria.
|
||||||
|
|
||||||
|
### Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
src/features/{feature_name}/
|
||||||
|
components/
|
||||||
|
FeatureMain.tsx # Componente principal
|
||||||
|
FeatureDetail.tsx # Sub-componentes
|
||||||
|
hooks/
|
||||||
|
useFeatureData.ts # Hooks del feature
|
||||||
|
types.ts # Tipos locales
|
||||||
|
index.ts # Barrel export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Barrel export (index.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Solo exportar la API publica del feature
|
||||||
|
export { FeatureMain } from './components/FeatureMain'
|
||||||
|
export { useFeatureData } from './hooks/useFeatureData'
|
||||||
|
export type { FeatureItem, FeatureConfig } from './types'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patrones de estado obligatorios
|
||||||
|
|
||||||
|
**Server state** (datos de API/backend):
|
||||||
|
```tsx
|
||||||
|
// Con @tanstack/react-query
|
||||||
|
const queryKeys = {
|
||||||
|
all: ['feature'] as const,
|
||||||
|
list: (filters: Filters) => [...queryKeys.all, 'list', filters] as const,
|
||||||
|
detail: (id: string) => [...queryKeys.all, 'detail', id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFeatureList(filters: Filters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.list(filters),
|
||||||
|
queryFn: () => fetchFeatureList(filters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client state** (UI state compartido):
|
||||||
|
```tsx
|
||||||
|
// Con Zustand
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface FeatureStore {
|
||||||
|
selectedId: string | null
|
||||||
|
setSelected: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFeatureStore = create<FeatureStore>((set) => ({
|
||||||
|
selectedId: null,
|
||||||
|
setSelected: (id) => set({ selectedId: id }),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wails** (apps de escritorio):
|
||||||
|
```tsx
|
||||||
|
// Usar hooks del registry
|
||||||
|
import { useWailsQuery, useWailsMutation } from '@fn_library'
|
||||||
|
|
||||||
|
function useFeatureData() {
|
||||||
|
return useWailsQuery('GetFeatureData', [], { staleTime: 60_000 })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code splitting por ruta
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { Skeleton } from '@mantine/core'
|
||||||
|
|
||||||
|
const FeaturePage = lazy(() => import('./features/feature/components/FeaturePage'))
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/feature" element={
|
||||||
|
<Suspense fallback={<Skeleton height="100vh" />}>
|
||||||
|
<FeaturePage />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CHECKLIST DE VALIDACION (ejecutar siempre al final)
|
||||||
|
|
||||||
|
Antes de dar por terminado cualquier trabajo frontend, verificar:
|
||||||
|
|
||||||
|
### Colores y estilos
|
||||||
|
- [ ] CERO colores hardcodeados en componentes (no hex, no rgb inline)
|
||||||
|
- [ ] Styling via props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, `gap`, etc.)
|
||||||
|
- [ ] Si se necesitan styles inline, usar CSS variables de Mantine (`var(--mantine-color-*)`)
|
||||||
|
- [ ] NO clases CSS manuales, NO Tailwind, NO cn(), NO CVA
|
||||||
|
|
||||||
|
### Componentes del registry
|
||||||
|
- [ ] Verificado que no se esta recreando algo que ya existe en `@fn_library` (`frontend/functions/ui/`)
|
||||||
|
- [ ] Componentes de `@fn_library` usados donde aplica: Card, Select, SimpleSelect, KPICard, Sparkline, DashboardLayout, DataTable, charts, hooks Wails
|
||||||
|
- [ ] Componentes de Mantine usados directamente donde `@fn_library` no tiene wrapper: Button, TextInput, Table, Alert, Badge, Skeleton, Tabs, Tooltip, Group, Stack, Grid, Box, Paper, AppShell, Container
|
||||||
|
|
||||||
|
### Iconos
|
||||||
|
- [ ] Usando `@phosphor-icons/react` para iconos
|
||||||
|
- [ ] NO lucide-react, NO @tabler/icons-react
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- [ ] Props interfaces con `React.ComponentPropsWithoutRef` para HTML spreading
|
||||||
|
- [ ] Discriminated unions donde las props varian segun tipo/variante
|
||||||
|
- [ ] `as const` para arrays literales y config objects
|
||||||
|
- [ ] No `any` — usar `unknown` + type guards si es necesario
|
||||||
|
|
||||||
|
### Accesibilidad
|
||||||
|
- [ ] Elementos semanticos (button, a — no div onClick)
|
||||||
|
- [ ] `aria-label` en botones de solo icono
|
||||||
|
- [ ] `aria-invalid` + `aria-describedby` en inputs con validacion
|
||||||
|
- [ ] Focus trap en modales y popovers
|
||||||
|
- [ ] `prefers-reduced-motion` respetado (ya en app.css base)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Lazy loading en rutas (`React.lazy` + `Suspense`)
|
||||||
|
- [ ] `manualChunks` en vite.config para vendor splitting
|
||||||
|
- [ ] Sin barrel exports profundos que maten tree-shaking
|
||||||
|
- [ ] Listas largas virtualizadas si >100 items
|
||||||
|
|
||||||
|
### Estructura
|
||||||
|
- [ ] Features co-located: componente + hook + tipos + barrel en el mismo directorio
|
||||||
|
- [ ] Un `index.ts` por feature con API publica explicita
|
||||||
|
- [ ] Componentes reutilizables de la app en `src/components/`
|
||||||
|
- [ ] Tipos compartidos en `src/types/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ANTI-PATRONES (nunca hacer)
|
||||||
|
|
||||||
|
1. **`<div onClick={...}>`** → usar `<button>` o componente Mantine
|
||||||
|
2. **`style={{ color: '#3b82f6' }}`** → usar prop `c="blue"` o `var(--mantine-color-blue-6)`
|
||||||
|
3. **`import Button from './MyButton'`** cuando existe en Mantine → usar `import { Button } from '@mantine/core'`
|
||||||
|
4. **Estado global para todo** → segmentar: server state (React Query), client state (Zustand), form state (React Hook Form), URL state (search params)
|
||||||
|
5. **`index.ts` en la raiz de `src/`** que re-exporta todo → mata tree-shaking
|
||||||
|
6. **`// @ts-ignore`** → arreglar el tipo
|
||||||
|
7. **CSS-in-JS runtime** (styled-components, emotion) → usar props de Mantine
|
||||||
|
8. **Tailwind, CVA, cn(), clsx** → usar props de Mantine y su style system
|
||||||
|
9. **Crear utilidades que ya existen**: `getSeriesColor()`, `ChartContainer`, `DashboardLayout`, `DataTable` ya estan en `@fn_library`
|
||||||
|
10. **Colores de chart hardcodeados** → usar `@mantine/charts` color system o `getSeriesColor()`
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# /meta_bigq — Operar Metabase y BigQuery desde el registry
|
||||||
|
|
||||||
|
Eres un agente de datos. Tienes acceso a funciones Python del fn_registry para controlar **Metabase** (dashboards, cards, queries, usuarios) y **Google BigQuery** (datasets, tablas, queries, jobs, routines). Usa estas funciones directamente — no inventes llamadas HTTP manuales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como ejecutar funciones
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHON="python/.venv/bin/python3"
|
||||||
|
|
||||||
|
# Ejecutar codigo inline
|
||||||
|
$PYTHON -c "
|
||||||
|
import sys; sys.path.insert(0, 'python/functions')
|
||||||
|
from metabase import metabase_auth, metabase_list_dashboards
|
||||||
|
client = metabase_auth('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
|
||||||
|
print(metabase_list_dashboards(client))
|
||||||
|
"
|
||||||
|
|
||||||
|
# O con fn run para pipelines
|
||||||
|
./fn run init_metabase --project fn_registry
|
||||||
|
./fn run setup_metabase_volume
|
||||||
|
./fn run metabase_create_ops_dashboard docker_tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables de entorno tipicas:
|
||||||
|
- `METABASE_URL` (default: `http://localhost:3000`)
|
||||||
|
- `METABASE_ADMIN_EMAIL` (default: `admin@fnregistry.local`)
|
||||||
|
- `METABASE_ADMIN_PASSWORD` (default: `FnRegistry2024!`)
|
||||||
|
- BigQuery usa ADC (`gcloud auth application-default login`) o `GOOGLE_APPLICATION_CREDENTIALS`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## METABASE — Referencia rapida
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import metabase_auth, MetabaseClient
|
||||||
|
|
||||||
|
# Login con email/password
|
||||||
|
client = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
|
||||||
|
|
||||||
|
# O directo con API key
|
||||||
|
client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx")
|
||||||
|
|
||||||
|
# Context manager
|
||||||
|
with metabase_auth(...) as client:
|
||||||
|
pass # se cierra solo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards (preguntas)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import (
|
||||||
|
metabase_list_cards, # (client, filter="", model_id=0) -> list[dict]
|
||||||
|
metabase_get_card, # (client, card_id) -> dict
|
||||||
|
metabase_create_card, # (client, name, dataset_query, display="table", collection_id=0, description="") -> dict
|
||||||
|
metabase_update_card, # (client, card_id, **fields) -> dict # fields: name, description, display, dataset_query, archived...
|
||||||
|
metabase_delete_card, # (client, card_id) -> None # IRREVERSIBLE, preferir archived=True
|
||||||
|
metabase_execute_card, # (client, card_id, parameters=None) -> dict # ejecuta query de card guardada
|
||||||
|
metabase_execute_query, # (client, database_id, sql, max_results=0) -> dict # query ad-hoc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear card con SQL nativo
|
||||||
|
card = metabase_create_card(client, "Ventas por mes", {
|
||||||
|
"database": 1, "type": "native",
|
||||||
|
"native": {"query": "SELECT date_trunc('month', created_at) as mes, SUM(total) FROM orders GROUP BY 1"},
|
||||||
|
}, display="line")
|
||||||
|
|
||||||
|
# Actualizar query de una card
|
||||||
|
metabase_update_card(client, card["id"], dataset_query={
|
||||||
|
"database": 1, "type": "native",
|
||||||
|
"native": {"query": "SELECT ... nueva query ..."},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Archivar (soft-delete)
|
||||||
|
metabase_update_card(client, 42, archived=True)
|
||||||
|
|
||||||
|
# Query ad-hoc sin guardar
|
||||||
|
result = metabase_execute_query(client, 1, "SELECT COUNT(*) FROM users")
|
||||||
|
# result["data"]["rows"] = [[42]]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filtros de list_cards:** `all`, `mine`, `fav`, `archived`, `recent`, `popular`, `database`, `table`
|
||||||
|
|
||||||
|
### Dashboards
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import (
|
||||||
|
metabase_list_dashboards, # (client, filter="") -> list[dict]
|
||||||
|
metabase_get_dashboard, # (client, dashboard_id) -> dict # incluye dashcards
|
||||||
|
metabase_create_dashboard, # (client, name, description="", collection_id=0) -> dict
|
||||||
|
metabase_update_dashboard, # (client, dashboard_id, **fields) -> dict
|
||||||
|
metabase_delete_dashboard, # (client, dashboard_id) -> None # IRREVERSIBLE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear dashboard + agregar cards
|
||||||
|
dash = metabase_create_dashboard(client, "KPIs Operativos", description="Metricas diarias")
|
||||||
|
|
||||||
|
# Posicionar cards en el dashboard (dashcards es el estado COMPLETO)
|
||||||
|
metabase_update_dashboard(client, dash["id"], dashcards=[
|
||||||
|
{"id": -1, "card_id": card1["id"], "row": 0, "col": 0, "size_x": 6, "size_y": 4},
|
||||||
|
{"id": -2, "card_id": card2["id"], "row": 0, "col": 6, "size_x": 6, "size_y": 4},
|
||||||
|
{"id": -3, "card_id": card3["id"], "row": 4, "col": 0, "size_x": 12, "size_y": 6},
|
||||||
|
])
|
||||||
|
# id negativo = card nueva, id positivo = card existente, omitida = eliminada
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filtros de list_dashboards:** `all`, `mine`, `archived`
|
||||||
|
|
||||||
|
### Databases
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import (
|
||||||
|
metabase_list_databases, # (client, include_tables=False) -> list
|
||||||
|
metabase_add_database, # (client, name, engine, details) -> dict
|
||||||
|
metabase_get_database, # (client, database_id) -> dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agregar SQLite
|
||||||
|
metabase_add_database(client, "Operations DB", "sqlite", {"db": "/data/operations.db"})
|
||||||
|
|
||||||
|
# Agregar PostgreSQL
|
||||||
|
metabase_add_database(client, "DW", "postgres", {
|
||||||
|
"host": "localhost", "port": 5432, "dbname": "warehouse",
|
||||||
|
"user": "reader", "password": "secret",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import (
|
||||||
|
metabase_list_users, # (client, status="", query="", limit=0, offset=0) -> dict
|
||||||
|
metabase_get_user, # (client, user_id) -> dict
|
||||||
|
metabase_create_user, # (client, first_name, last_name, email, password="", group_ids=None) -> dict
|
||||||
|
metabase_update_user, # (client, user_id, **fields) -> dict
|
||||||
|
metabase_deactivate_user, # (client, user_id) -> None # soft-delete
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup y pipelines
|
||||||
|
|
||||||
|
```python
|
||||||
|
from metabase import metabase_setup
|
||||||
|
|
||||||
|
# Setup inicial de instancia nueva (obtiene setup-token automaticamente)
|
||||||
|
metabase_setup("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pipelines ejecutables con fn run
|
||||||
|
./fn run init_metabase --project fn_registry # Docker: Postgres + Metabase
|
||||||
|
./fn run setup_metabase_volume # Copiar registry.db al contenedor
|
||||||
|
./fn run metabase_add_ops_db docker_tui # Registrar operations.db como database
|
||||||
|
./fn run metabase_create_ops_dashboard docker_tui # Dashboard operativo completo
|
||||||
|
./fn run metabase_fix_permissions # Arreglar permisos SQLite en Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BIGQUERY — Referencia rapida
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import bq_auth, BQClient
|
||||||
|
|
||||||
|
# ADC (gcloud auth application-default login)
|
||||||
|
client = bq_auth()
|
||||||
|
|
||||||
|
# Proyecto explicito
|
||||||
|
client = bq_auth("my-project-id")
|
||||||
|
|
||||||
|
# Service account JSON
|
||||||
|
client = bq_auth(credentials_path="/path/to/sa.json")
|
||||||
|
|
||||||
|
# Context manager
|
||||||
|
with bq_auth("my-project") as client:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datasets
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import (
|
||||||
|
bq_create_dataset, # (client, dataset_id, location="US", description="", labels=None, default_table_expiration_ms=0) -> dict
|
||||||
|
bq_get_dataset, # (client, dataset_id) -> dict
|
||||||
|
bq_list_datasets, # (client) -> list[dict]
|
||||||
|
bq_update_dataset, # (client, dataset_id, description=None, labels=None, default_table_expiration_ms=None) -> dict
|
||||||
|
bq_delete_dataset, # (client, dataset_id, delete_contents=False) -> None
|
||||||
|
)
|
||||||
|
|
||||||
|
bq_create_dataset(client, "analytics", location="EU", description="Data warehouse")
|
||||||
|
bq_delete_dataset(client, "temp", delete_contents=True) # borra tablas incluidas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import (
|
||||||
|
bq_create_table, # (client, dataset_id, table_id, schema, partitioning=None, clustering=None, description="", labels=None) -> dict
|
||||||
|
bq_get_table, # (client, dataset_id, table_id) -> dict # schema, num_rows, num_bytes, partitioning...
|
||||||
|
bq_list_tables, # (client, dataset_id) -> list[dict]
|
||||||
|
bq_update_table, # (client, dataset_id, table_id, schema=None, description=None, labels=None) -> dict
|
||||||
|
bq_delete_table, # (client, dataset_id, table_id) -> None
|
||||||
|
bq_preview_rows, # (client, dataset_id, table_id, max_results=10) -> dict # SIN COSTE de query
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear tabla con particionamiento
|
||||||
|
bq_create_table(client, "analytics", "events",
|
||||||
|
schema=[
|
||||||
|
{"name": "event_id", "type": "STRING", "mode": "REQUIRED"},
|
||||||
|
{"name": "user_id", "type": "STRING"},
|
||||||
|
{"name": "event_type", "type": "STRING"},
|
||||||
|
{"name": "created_at", "type": "TIMESTAMP"},
|
||||||
|
{"name": "payload", "type": "JSON"},
|
||||||
|
],
|
||||||
|
partitioning={"type": "DAY", "field": "created_at"},
|
||||||
|
clustering=["event_type", "user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preview sin coste (usa Storage Read API, no ejecuta query)
|
||||||
|
preview = bq_preview_rows(client, "analytics", "events", max_results=5)
|
||||||
|
# {"columns": [...], "rows": [[...], ...], "total_rows": 1234567}
|
||||||
|
|
||||||
|
# Schema: solo se pueden AGREGAR columnas, nunca eliminar
|
||||||
|
bq_update_table(client, "analytics", "events", schema=[
|
||||||
|
*existing_schema,
|
||||||
|
{"name": "new_col", "type": "STRING"},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipos de schema:** `STRING`, `INT64`, `FLOAT64`, `BOOL`, `TIMESTAMP`, `DATE`, `DATETIME`, `BYTES`, `NUMERIC`, `JSON`, `RECORD`/`STRUCT`, `GEOGRAPHY`
|
||||||
|
**Modos:** `NULLABLE` (default), `REQUIRED`, `REPEATED`
|
||||||
|
|
||||||
|
### Queries y datos
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import (
|
||||||
|
bq_query, # (client, sql, params=None, dry_run=False) -> dict
|
||||||
|
bq_insert_rows, # (client, dataset_id, table_id, rows) -> dict
|
||||||
|
bq_load_from_gcs, # (client, uri, dataset_id, table_id, source_format="CSV", write_disposition="WRITE_APPEND", autodetect=True, skip_leading_rows=0) -> dict
|
||||||
|
bq_load_from_file, # (client, file_path, dataset_id, table_id, ...) -> dict # mismos params que gcs
|
||||||
|
bq_export_to_gcs, # (client, dataset_id, table_id, destination_uri, destination_format="CSV", compression="NONE") -> dict
|
||||||
|
bq_copy_table, # (client, source_dataset, source_table, dest_dataset, dest_table, write_disposition="WRITE_EMPTY") -> dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query simple
|
||||||
|
result = bq_query(client, "SELECT COUNT(*) as total FROM analytics.events")
|
||||||
|
# {"columns": ["total"], "rows": [[1234567]], "total_rows": 1, "bytes_processed": 0, "cache_hit": True}
|
||||||
|
|
||||||
|
# Query parametrizada (usa @nombre en SQL)
|
||||||
|
result = bq_query(client, "SELECT * FROM analytics.events WHERE event_type = @tipo LIMIT @n", params=[
|
||||||
|
{"name": "tipo", "type": "STRING", "value": "purchase"},
|
||||||
|
{"name": "n", "type": "INT64", "value": 100},
|
||||||
|
])
|
||||||
|
|
||||||
|
# Estimar coste ANTES de ejecutar (no procesa datos)
|
||||||
|
estimate = bq_query(client, "SELECT * FROM analytics.events", dry_run=True)
|
||||||
|
# {"total_bytes_processed": 5368709120, "total_bytes_billed": 5368709120}
|
||||||
|
gb = estimate["total_bytes_processed"] / (1024**3)
|
||||||
|
print(f"Esta query procesara {gb:.2f} GB (~${gb * 6.25:.2f} USD)")
|
||||||
|
|
||||||
|
# Streaming insert
|
||||||
|
bq_insert_rows(client, "analytics", "events", [
|
||||||
|
{"event_id": "e1", "user_id": "u1", "event_type": "click", "created_at": "2026-04-07T10:00:00Z"},
|
||||||
|
{"event_id": "e2", "user_id": "u2", "event_type": "purchase", "created_at": "2026-04-07T10:01:00Z"},
|
||||||
|
])
|
||||||
|
# {"inserted": 2, "errors": []}
|
||||||
|
|
||||||
|
# Cargar CSV desde GCS
|
||||||
|
bq_load_from_gcs(client, "gs://bucket/data/*.csv", "analytics", "events",
|
||||||
|
source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)
|
||||||
|
|
||||||
|
# Cargar archivo local
|
||||||
|
bq_load_from_file(client, "/tmp/data.parquet", "analytics", "events",
|
||||||
|
source_format="PARQUET", write_disposition="WRITE_APPEND")
|
||||||
|
|
||||||
|
# Exportar a GCS
|
||||||
|
bq_export_to_gcs(client, "analytics", "events", "gs://bucket/export/events-*.csv",
|
||||||
|
destination_format="CSV", compression="GZIP")
|
||||||
|
|
||||||
|
# Copiar tabla
|
||||||
|
bq_copy_table(client, "analytics", "events", "analytics_backup", "events_20260407")
|
||||||
|
```
|
||||||
|
|
||||||
|
**write_disposition:** `WRITE_TRUNCATE` (reemplazar), `WRITE_APPEND` (agregar), `WRITE_EMPTY` (solo si vacia)
|
||||||
|
**source_format:** `CSV`, `NEWLINE_DELIMITED_JSON`, `AVRO`, `PARQUET`, `ORC`
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import (
|
||||||
|
bq_list_jobs, # (client, state_filter="", max_results=50, all_users=False) -> list[dict]
|
||||||
|
bq_get_job, # (client, job_id) -> dict # state, bytes_processed, errors
|
||||||
|
bq_cancel_job, # (client, job_id) -> dict
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ver jobs corriendo
|
||||||
|
running = bq_list_jobs(client, state_filter="running")
|
||||||
|
for j in running:
|
||||||
|
print(j["job_id"], j["job_type"], j["bytes_processed"])
|
||||||
|
|
||||||
|
# Cancelar un job pesado
|
||||||
|
bq_cancel_job(client, "job_abc123")
|
||||||
|
```
|
||||||
|
|
||||||
|
**state_filter:** `running`, `pending`, `done`
|
||||||
|
|
||||||
|
### Routines (UDFs / Procedures)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import (
|
||||||
|
bq_create_routine, # (client, dataset_id, routine_id, body, routine_type="SCALAR_FUNCTION", language="SQL", arguments=None, return_type="", description="") -> dict
|
||||||
|
bq_list_routines, # (client, dataset_id) -> list[dict]
|
||||||
|
bq_delete_routine, # (client, dataset_id, routine_id) -> None
|
||||||
|
)
|
||||||
|
|
||||||
|
# UDF SQL
|
||||||
|
bq_create_routine(client, "analytics", "double_value",
|
||||||
|
body="x * 2",
|
||||||
|
arguments=[{"name": "x", "data_type": "INT64"}],
|
||||||
|
return_type="INT64",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stored procedure
|
||||||
|
bq_create_routine(client, "analytics", "refresh_summary",
|
||||||
|
body="BEGIN INSERT INTO summary SELECT ... FROM events; END;",
|
||||||
|
routine_type="PROCEDURE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# UDF JavaScript
|
||||||
|
bq_create_routine(client, "analytics", "parse_ua",
|
||||||
|
body="return uaParser.parse(ua).browser.name;",
|
||||||
|
language="JAVASCRIPT",
|
||||||
|
arguments=[{"name": "ua", "data_type": "STRING"}],
|
||||||
|
return_type="STRING",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flujos tipicos
|
||||||
|
|
||||||
|
### 1. Explorar BigQuery y visualizar en Metabase
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys; sys.path.insert(0, "python/functions")
|
||||||
|
from bigquery import bq_auth, bq_query
|
||||||
|
from metabase import metabase_auth, metabase_create_card, metabase_create_dashboard, metabase_update_dashboard
|
||||||
|
|
||||||
|
# 1. Explorar datos en BQ
|
||||||
|
bq = bq_auth("my-project")
|
||||||
|
result = bq_query(bq, "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC LIMIT 10")
|
||||||
|
print(result["columns"], result["rows"])
|
||||||
|
|
||||||
|
# 2. Registrar BQ como database en Metabase (si no esta)
|
||||||
|
# Metabase soporta BigQuery como engine nativo
|
||||||
|
|
||||||
|
# 3. Crear cards en Metabase apuntando a BQ
|
||||||
|
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
|
||||||
|
card = metabase_create_card(mb, "Eventos por tipo", {
|
||||||
|
"database": 2, # ID de la database BQ en Metabase
|
||||||
|
"type": "native",
|
||||||
|
"native": {"query": "SELECT event_type, COUNT(*) as cnt FROM analytics.events GROUP BY 1 ORDER BY 2 DESC"},
|
||||||
|
}, display="bar")
|
||||||
|
|
||||||
|
# 4. Crear dashboard
|
||||||
|
dash = metabase_create_dashboard(mb, "Analytics Overview")
|
||||||
|
metabase_update_dashboard(mb, dash["id"], dashcards=[
|
||||||
|
{"id": -1, "card_id": card["id"], "row": 0, "col": 0, "size_x": 12, "size_y": 6},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ETL: archivo local -> BigQuery -> Metabase dashboard
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import bq_auth, bq_load_from_file, bq_query, bq_preview_rows
|
||||||
|
from metabase import metabase_auth, metabase_execute_query
|
||||||
|
|
||||||
|
bq = bq_auth("my-project")
|
||||||
|
|
||||||
|
# Cargar datos
|
||||||
|
bq_load_from_file(bq, "/tmp/sales.csv", "warehouse", "sales",
|
||||||
|
source_format="CSV", write_disposition="WRITE_TRUNCATE", skip_leading_rows=1)
|
||||||
|
|
||||||
|
# Verificar
|
||||||
|
preview = bq_preview_rows(bq, "warehouse", "sales", max_results=3)
|
||||||
|
print(preview["total_rows"], "filas cargadas")
|
||||||
|
|
||||||
|
# Consultar via Metabase (si BQ esta registrado como database)
|
||||||
|
mb = metabase_auth("http://localhost:3000", "admin@fnregistry.local", "FnRegistry2024!")
|
||||||
|
result = metabase_execute_query(mb, 2, "SELECT region, SUM(amount) FROM sales GROUP BY 1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Montar infraestructura desde cero
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Levantar Metabase + Postgres
|
||||||
|
./fn run init_metabase --project fn_registry
|
||||||
|
|
||||||
|
# 2. Copiar registry.db al contenedor
|
||||||
|
./fn run setup_metabase_volume
|
||||||
|
|
||||||
|
# 3. Setup inicial
|
||||||
|
python/.venv/bin/python3 -c "
|
||||||
|
import sys; sys.path.insert(0, 'python/functions')
|
||||||
|
from metabase import metabase_setup
|
||||||
|
metabase_setup('http://localhost:3000', 'admin@fnregistry.local', 'FnRegistry2024!')
|
||||||
|
"
|
||||||
|
|
||||||
|
# 4. Registrar operations.db de una app
|
||||||
|
./fn run metabase_add_ops_db docker_tui
|
||||||
|
|
||||||
|
# 5. Dashboard operativo automatico
|
||||||
|
./fn run metabase_create_ops_dashboard docker_tui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Auditar costes de BigQuery
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bigquery import bq_auth, bq_list_jobs, bq_query
|
||||||
|
|
||||||
|
bq = bq_auth("my-project")
|
||||||
|
|
||||||
|
# Jobs recientes completados
|
||||||
|
jobs = bq_list_jobs(bq, state_filter="done", max_results=20, all_users=True)
|
||||||
|
total_bytes = sum(j.get("bytes_processed") or 0 for j in jobs)
|
||||||
|
print(f"Ultimos 20 jobs: {total_bytes / (1024**3):.2f} GB procesados")
|
||||||
|
|
||||||
|
# Dry-run antes de queries caras
|
||||||
|
estimate = bq_query(bq, "SELECT * FROM analytics.events WHERE created_at > '2026-01-01'", dry_run=True)
|
||||||
|
gb = estimate["total_bytes_processed"] / (1024**3)
|
||||||
|
cost = gb * 6.25 # $6.25/TB on-demand
|
||||||
|
print(f"Coste estimado: ${cost:.2f} USD ({gb:.1f} GB)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buscar mas funciones
|
||||||
|
|
||||||
|
Si necesitas algo que no esta aqui, busca en el registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# FTS5 por nombre o descripcion
|
||||||
|
./fn search "lo que buscas"
|
||||||
|
|
||||||
|
# Ver detalles de una funcion
|
||||||
|
./fn show <id>
|
||||||
|
|
||||||
|
# Inline desde Python
|
||||||
|
sqlite3 registry.db "SELECT id, description FROM functions WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH 'description:export*') ORDER BY name;"
|
||||||
|
```
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -13,3 +13,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
|||||||
| 07 | [proposals.md](proposals.md) | Quien crea proposals y cuando |
|
| 07 | [proposals.md](proposals.md) | Quien crea proposals y cuando |
|
||||||
| 08 | [tag_launcher.md](tag_launcher.md) | Tag launcher para Pipeline Launcher TUI |
|
| 08 | [tag_launcher.md](tag_launcher.md) | Tag launcher para Pipeline Launcher TUI |
|
||||||
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
|
| 09 | [go_packages.md](go_packages.md) | Nombre de paquete Go = nombre del directorio |
|
||||||
|
| 10 | [apps_vs_functions.md](apps_vs_functions.md) | Codigo reutilizable en functions/, no reutilizable en apps/ |
|
||||||
|
| 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 |
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Solo codigo reutilizable y componible va en `functions/`, `python/functions/`, `bash/functions/`, `frontend/functions/`.
|
||||||
|
|
||||||
|
Scripts especificos, dashboards hardcodeados, CLIs de un solo uso, y cualquier codigo que no sea una primitiva componible va en `apps/`. Cada app en `apps/` es independiente: puede importar funciones del registry pero nunca al reves.
|
||||||
|
|
||||||
|
Criterios para decidir:
|
||||||
|
- **functions/**: firma generica, sin credenciales ni config hardcodeada, util en multiples contextos
|
||||||
|
- **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.`).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
En todos los frontends se usan los componentes de `@fn_library` (alias a `frontend/functions/ui/`) antes que elementos HTML nativos o librerias externas.
|
||||||
|
|
||||||
|
El sistema de UI es Mantine v9. Todos los componentes de @fn_library wrappean componentes de Mantine.
|
||||||
|
|
||||||
|
**Theming:** Cada app define su tema con `createTheme()` de `@mantine/core` y lo pasa a `MantineProvider` (o `FnMantineProvider` de @fn_library). No se usan CSS variables custom — Mantine genera las suyas automaticamente (`--mantine-color-*`).
|
||||||
|
|
||||||
|
**Styling:** No se usa Tailwind, CVA, cn(), ni clases CSS manuales. Los componentes se estilizan con props de Mantine (`size`, `color`, `variant`, `p`, `m`, `fw`, etc.) y el style system de Mantine.
|
||||||
|
|
||||||
|
**Iconos:** Se usa `@tabler/icons-react` (el set nativo de Mantine), no lucide-react.
|
||||||
|
|
||||||
|
**Layout:** Se usan los componentes de layout de Mantine: `Group`, `Stack`, `Grid`, `Flex`, `SimpleGrid`, `AppShell`, `Container`, `Box`, `Paper`.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## Colaboración en notebooks Jupyter
|
||||||
|
|
||||||
|
### Requisito previo
|
||||||
|
|
||||||
|
El usuario debe tener Jupyter Lab corriendo en modo colaborativo (`--collaborative`) y el notebook abierto en el browser. Sin esto, los cambios no se ven en tiempo real.
|
||||||
|
|
||||||
|
El launcher estándar (`run-jupyter-lab.sh` generado por `init_jupyter_analysis`) ya incluye `--collaborative`.
|
||||||
|
|
||||||
|
### Funciones del registry (dominio `notebook`)
|
||||||
|
|
||||||
|
| Función | ID | Para qué |
|
||||||
|
|---|---|---|
|
||||||
|
| `jupyter_discover` | `jupyter_discover_py_notebook` | Descubrir instancias Jupyter activas, kernels, sesiones, modo colaborativo |
|
||||||
|
| `jupyter_read` | `jupyter_read_py_notebook` | Leer celdas (todas o una), metadata del notebook |
|
||||||
|
| `jupyter_exec` | `jupyter_exec_py_notebook` | Ejecutar: append+execute, execute celda existente, o directo al kernel |
|
||||||
|
| `jupyter_write` | `jupyter_write_py_notebook` | Escribir: append code/markdown, insert, edit, delete celdas |
|
||||||
|
| `jupyter_kernel` | `jupyter_kernel_py_notebook` | CRUD de kernels: list, start, restart, interrupt, shutdown, sessions |
|
||||||
|
|
||||||
|
### Invocación desde cualquier sesión de Claude
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHON="python/.venv/bin/python3"
|
||||||
|
|
||||||
|
# 1. Descubrir qué Jupyter está corriendo
|
||||||
|
$PYTHON python/functions/notebook/jupyter_discover.py --json
|
||||||
|
|
||||||
|
# 2. Leer notebook
|
||||||
|
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
|
||||||
|
|
||||||
|
# 3. Añadir celda y ejecutar (el usuario la ve en tiempo real)
|
||||||
|
$PYTHON python/functions/notebook/jupyter_exec.py append notebooks/01.ipynb "df.describe()"
|
||||||
|
|
||||||
|
# 4. Ejecutar celda existente
|
||||||
|
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/01.ipynb 3
|
||||||
|
|
||||||
|
# 5. Ejecutar en kernel sin tocar notebook
|
||||||
|
$PYTHON python/functions/notebook/jupyter_exec.py kernel "print(df.shape)"
|
||||||
|
|
||||||
|
# 6. Añadir markdown
|
||||||
|
$PYTHON python/functions/notebook/jupyter_write.py append-markdown notebooks/01.ipynb "## Resumen"
|
||||||
|
|
||||||
|
# 7. Gestionar kernels
|
||||||
|
$PYTHON python/functions/notebook/jupyter_kernel.py list
|
||||||
|
$PYTHON python/functions/notebook/jupyter_kernel.py sessions
|
||||||
|
$PYTHON python/functions/notebook/jupyter_kernel.py shutdown <kernel_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas de uso
|
||||||
|
|
||||||
|
- **SIEMPRE** ejecutar `jupyter_discover` primero para confirmar que Jupyter está activo y el notebook abierto.
|
||||||
|
- Las funciones resuelven automáticamente el `kernel_id` de la sesión del notebook y el `username` colaborativo via `/api/sessions` y `/api/me`.
|
||||||
|
- Después de escribir/ejecutar, las funciones mantienen la conexión WebSocket 2 segundos para que Y.js propague los cambios al browser.
|
||||||
|
- **NO usar MCP jupyter** — estas funciones reemplazan al MCP y funcionan desde cualquier directorio sin registrar nada.
|
||||||
|
- El token por defecto es vacío (sin auth). Si el server tiene token, pasarlo con `--token`.
|
||||||
|
- Los paths de notebooks son relativos a la raíz del servidor Jupyter (normalmente `analysis/{tema}/`).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## Extraccion de funciones desde repos externos (`sources/`)
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. Clonar repo en `sources/<nombre>` (gitignored, solo el manifest `sources/sources.yaml` se versiona)
|
||||||
|
2. El agente analiza el repo y propone funciones candidatas
|
||||||
|
3. Las funciones se **copian y adaptan** al formato del registry (.go/.py/.sh/.ts + .md con frontmatter)
|
||||||
|
4. `fn index` las registra. El manifest se actualiza con las funciones extraidas.
|
||||||
|
|
||||||
|
### Filtro de calidad (obligatorio antes de extraer)
|
||||||
|
|
||||||
|
Una funcion externa solo se extrae si cumple TODOS estos criterios:
|
||||||
|
|
||||||
|
- **Firma generica**: no depende de tipos internos del repo origen ni de config hardcodeada
|
||||||
|
- **Sin estado global**: no usa variables globales, singletons, ni init() con side effects
|
||||||
|
- **Dependencias minimas**: solo stdlib o dependencias ya presentes en fn_registry
|
||||||
|
- **Sin credenciales**: no contiene secrets, API keys, ni paths absolutos
|
||||||
|
- **Testeable**: la logica debe poder validarse con tests unitarios
|
||||||
|
- **No duplicada**: consultar registry.db con FTS5 antes de extraer para evitar duplicados
|
||||||
|
- **Licencia compatible**: el repo debe tener licencia permisiva (MIT, Apache 2.0, BSD, etc.)
|
||||||
|
|
||||||
|
### Clasificacion de pureza al extraer
|
||||||
|
|
||||||
|
Extraer tanto funciones puras como impuras. La clasificacion correcta es obligatoria:
|
||||||
|
|
||||||
|
- **Pure**: sin I/O, sin estado mutable, determinista. Extraer como `purity: pure`.
|
||||||
|
- **Impure**: hace I/O (red, disco, DB, HTTP), usa concurrencia, o depende de estado externo. Extraer como `purity: impure` con `error_type` apropiado.
|
||||||
|
- **Pipeline**: compone multiples funciones para un flujo completo. Extraer como `kind: pipeline`, siempre impuro.
|
||||||
|
|
||||||
|
No descartar funciones utiles solo por ser impuras. Una funcion que hace HTTP requests, lee archivos, o interactua con bases de datos es valiosa si su firma es generica y reutilizable.
|
||||||
|
|
||||||
|
### Adaptacion al extraer
|
||||||
|
|
||||||
|
- Renombrar a snake_case siguiendo la convencion del registry
|
||||||
|
- Adaptar firma para usar tipos nativos (no tipos internos del repo)
|
||||||
|
- Crear .md con frontmatter completo incluyendo `source_repo`, `source_license`, `source_file`
|
||||||
|
- Actualizar `sources/sources.yaml` con la extraccion
|
||||||
|
|
||||||
|
### Campos de atribucion en frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
source_repo: "https://github.com/user/project"
|
||||||
|
source_license: "MIT"
|
||||||
|
source_file: "pkg/original_file.go"
|
||||||
|
```
|
||||||
|
|
||||||
|
Estos campos se indexan en registry.db y permiten consultar:
|
||||||
|
```sql
|
||||||
|
SELECT id, source_repo, source_license FROM functions WHERE source_repo != '';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lenguajes soportados para extraccion
|
||||||
|
|
||||||
|
Cualquier lenguaje puede analizarse como fuente. El destino depende de la naturaleza de la funcion:
|
||||||
|
- Algoritmos/logica pura → Go (functions/{domain}/) o Python (python/functions/{domain}/)
|
||||||
|
- Funciones impuras (I/O, HTTP, DB) → Go o Python segun el dominio
|
||||||
|
- Scripts/utilidades sistema → Bash (bash/functions/{domain}/)
|
||||||
|
- UI/frontend → TypeScript (frontend/functions/{domain}/)
|
||||||
|
- Flujos multi-paso → Pipeline en el lenguaje mas natural
|
||||||
|
- C/Rust/otros → Traducir a Go o Python, manteniendo la semantica original
|
||||||
+29
-2
@@ -1,5 +1,4 @@
|
|||||||
# SQLite index (regenerable con fn index) — SOLO en raiz
|
# SQLite index — journal/wal temporales
|
||||||
registry.db
|
|
||||||
registry.db-journal
|
registry.db-journal
|
||||||
registry.db-wal
|
registry.db-wal
|
||||||
|
|
||||||
@@ -24,6 +23,34 @@ registry.db-wal
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Python
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
python/.venv/
|
||||||
|
|
||||||
|
# Externalized apps and analysis (each is its own Gitea repo)
|
||||||
|
apps/*/
|
||||||
|
analysis/*/
|
||||||
|
|
||||||
|
# Node / pnpm
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||||
|
sources/*/
|
||||||
|
|
||||||
|
# C++ build artifacts
|
||||||
|
cpp/build/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Archivos locales
|
||||||
|
.local
|
||||||
|
|
||||||
|
broken_paths.txt
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
[submodule "cpp/vendor/imgui"]
|
||||||
|
path = cpp/vendor/imgui
|
||||||
|
url = https://github.com/ocornut/imgui.git
|
||||||
|
branch = docking
|
||||||
|
[submodule "cpp/vendor/implot"]
|
||||||
|
path = cpp/vendor/implot
|
||||||
|
url = https://github.com/epezent/implot.git
|
||||||
|
[submodule "cpp/vendor/tracy"]
|
||||||
|
path = cpp/vendor/tracy
|
||||||
|
url = https://github.com/wolfpld/tracy.git
|
||||||
|
[submodule "/home/lucas/fn_registry/cpp/vendor/glfw"]
|
||||||
|
path = /home/lucas/fn_registry/cpp/vendor/glfw
|
||||||
|
url = https://github.com/glfw/glfw.git
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
build/
|
|
||||||
*.exe
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.PHONY: run build clean install tidy help
|
|
||||||
|
|
||||||
run: ## Ejecuta la TUI
|
|
||||||
go run .
|
|
||||||
|
|
||||||
build: ## Compila el binario
|
|
||||||
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
|
|
||||||
|
|
||||||
clean: ## Limpia artefactos
|
|
||||||
rm -rf build/
|
|
||||||
|
|
||||||
install: build ## Instala en ~/.local/bin
|
|
||||||
cp build/docker-tui ~/.local/bin/docker-tui
|
|
||||||
|
|
||||||
tidy: ## go mod tidy
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
help: ## Muestra esta ayuda
|
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"docker-tui/views"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type View int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ViewContainers View = iota
|
|
||||||
ViewImages
|
|
||||||
ViewVolumes
|
|
||||||
ViewNetworks
|
|
||||||
ViewCompose
|
|
||||||
)
|
|
||||||
|
|
||||||
var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"}
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
tui.BaseModel
|
|
||||||
activeTab int
|
|
||||||
containers views.ContainersModel
|
|
||||||
images views.ImagesModel
|
|
||||||
volumes views.VolumesModel
|
|
||||||
networks views.NetworksModel
|
|
||||||
compose views.ComposeModel
|
|
||||||
ready bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() Model {
|
|
||||||
styles := tui.DefaultStyles()
|
|
||||||
return Model{
|
|
||||||
BaseModel: tui.NewBaseModel().WithStyles(styles),
|
|
||||||
containers: views.NewContainersModel(styles),
|
|
||||||
images: views.NewImagesModel(styles),
|
|
||||||
volumes: views.NewVolumesModel(styles),
|
|
||||||
networks: views.NewNetworksModel(styles),
|
|
||||||
compose: views.NewComposeModel(styles),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
return m.containers.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case views.KeyQuit:
|
|
||||||
return m, tea.Quit
|
|
||||||
case "q", "0", "esc":
|
|
||||||
updated, atBase := m.handleBack()
|
|
||||||
if atBase {
|
|
||||||
return updated, tea.Quit
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
case views.KeyTab:
|
|
||||||
m.activeTab = (m.activeTab + 1) % len(tabNames)
|
|
||||||
return m, m.initActiveView()
|
|
||||||
case "shift+tab":
|
|
||||||
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
|
|
||||||
return m, m.initActiveView()
|
|
||||||
}
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.HandleWindowSize(msg)
|
|
||||||
m.ready = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewContainers:
|
|
||||||
m.containers, cmd = m.containers.Update(msg)
|
|
||||||
case ViewImages:
|
|
||||||
m.images, cmd = m.images.Update(msg)
|
|
||||||
case ViewVolumes:
|
|
||||||
m.volumes, cmd = m.volumes.Update(msg)
|
|
||||||
case ViewNetworks:
|
|
||||||
m.networks, cmd = m.networks.Update(msg)
|
|
||||||
case ViewCompose:
|
|
||||||
m.compose, cmd = m.compose.Update(msg)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if !m.ready {
|
|
||||||
return "Loading..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab bar
|
|
||||||
tabs := m.renderTabs()
|
|
||||||
|
|
||||||
// Active view content
|
|
||||||
var content string
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewContainers:
|
|
||||||
content = m.containers.View()
|
|
||||||
case ViewImages:
|
|
||||||
content = m.images.View()
|
|
||||||
case ViewVolumes:
|
|
||||||
content = m.volumes.View()
|
|
||||||
case ViewNetworks:
|
|
||||||
content = m.networks.View()
|
|
||||||
case ViewCompose:
|
|
||||||
content = m.compose.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
|
||||||
tabs,
|
|
||||||
"",
|
|
||||||
content,
|
|
||||||
"",
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderTabs() string {
|
|
||||||
var tabs []string
|
|
||||||
for i, name := range tabNames {
|
|
||||||
if i == m.activeTab {
|
|
||||||
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
|
|
||||||
} else {
|
|
||||||
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
|
||||||
return m.Styles.Header.Render("Docker TUI") + " " + row
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBack asks the active view to go back one level.
|
|
||||||
// Returns the updated model and true if the view was already at base level (app should quit).
|
|
||||||
func (m Model) handleBack() (Model, bool) {
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewContainers:
|
|
||||||
atBase := m.containers.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
case ViewImages:
|
|
||||||
atBase := m.images.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
case ViewVolumes:
|
|
||||||
atBase := m.volumes.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
case ViewNetworks:
|
|
||||||
atBase := m.networks.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
case ViewCompose:
|
|
||||||
atBase := m.compose.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
}
|
|
||||||
return m, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) initActiveView() tea.Cmd {
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewContainers:
|
|
||||||
return m.containers.Init()
|
|
||||||
case ViewImages:
|
|
||||||
return m.images.Init()
|
|
||||||
case ViewVolumes:
|
|
||||||
return m.volumes.Init()
|
|
||||||
case ViewNetworks:
|
|
||||||
return m.networks.Init()
|
|
||||||
case ViewCompose:
|
|
||||||
return m.compose.Init()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "==> Tidying modules..."
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
echo "==> Building docker-tui..."
|
|
||||||
mkdir -p build
|
|
||||||
go build -trimpath -ldflags='-s -w' -o build/docker-tui .
|
|
||||||
|
|
||||||
echo "==> Done: build/docker-tui ($(du -h build/docker-tui | cut -f1))"
|
|
||||||
echo " Run with: ./build/docker-tui"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
// Config holds Docker TUI configuration.
|
|
||||||
type Config struct {
|
|
||||||
ComposeFile string
|
|
||||||
RefreshInterval int // seconds, 0 = manual
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default returns sensible defaults.
|
|
||||||
func Default() Config {
|
|
||||||
return Config{
|
|
||||||
ComposeFile: "docker-compose.yml",
|
|
||||||
RefreshInterval: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
module docker-tui
|
|
||||||
|
|
||||||
go 1.22.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1
|
|
||||||
github.com/lucasdataproyects/devfactory v0.0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/apache/arrow/go/v14 v14.0.2 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0 // indirect
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/google/flatbuffers v23.5.26+incompatible // indirect
|
|
||||||
github.com/klauspost/compress v1.16.7 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/marcboeker/go-duckdb v1.6.5 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.6 // indirect
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
|
||||||
golang.org/x/mod v0.13.0 // indirect
|
|
||||||
golang.org/x/sync v0.4.0 // indirect
|
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
|
||||||
golang.org/x/term v0.6.0 // indirect
|
|
||||||
golang.org/x/text v0.13.0 // indirect
|
|
||||||
golang.org/x/tools v0.14.0 // indirect
|
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw=
|
|
||||||
github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
|
||||||
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg=
|
|
||||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
|
||||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
|
||||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
|
||||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI=
|
|
||||||
github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs=
|
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
|
||||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
|
||||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
|
||||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
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=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
|
||||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
|
||||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
|
||||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
|
||||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
|
||||||
gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o=
|
|
||||||
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
go 1.22.2
|
|
||||||
|
|
||||||
use (
|
|
||||||
.
|
|
||||||
/home/lucas/.local_agentes/backend
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
|
|
||||||
github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
|
|
||||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
|
||||||
github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q=
|
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
|
||||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
|
||||||
github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng=
|
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
|
||||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
|
||||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
||||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY=
|
|
||||||
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
|
||||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
|
||||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
|
||||||
modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
|
||||||
modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0=
|
|
||||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"docker-tui/app"
|
|
||||||
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
model := app.New()
|
|
||||||
result := tui.RunFullscreen(model)
|
|
||||||
|
|
||||||
if result.IsErr() {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type composeState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
composeLoading composeState = iota
|
|
||||||
composeList
|
|
||||||
composeAction
|
|
||||||
composeLogs
|
|
||||||
)
|
|
||||||
|
|
||||||
type composeLoadedMsg []ComposeService
|
|
||||||
type composeActionMsg struct{ output string; err error }
|
|
||||||
type composeLogsMsg struct{ output string; err error }
|
|
||||||
|
|
||||||
type ComposeModel struct {
|
|
||||||
state composeState
|
|
||||||
list tui.ListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
services []ComposeService
|
|
||||||
output string
|
|
||||||
scrollOff int
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewComposeModel(styles tui.Styles) ComposeModel {
|
|
||||||
return ComposeModel{
|
|
||||||
state: composeLoading,
|
|
||||||
list: tui.NewList(nil),
|
|
||||||
spinner: tui.NewSpinner("Loading compose services..."),
|
|
||||||
styles: styles,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ComposeModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.spinner.Init(), loadCompose)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCompose() tea.Msg {
|
|
||||||
services, err := ComposePS()
|
|
||||||
if err != nil {
|
|
||||||
return composeLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return composeLoadedMsg(services)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ComposeModel) Update(msg tea.Msg) (ComposeModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case composeLoadedMsg:
|
|
||||||
m.services = []ComposeService(msg)
|
|
||||||
items := make([]tui.ListItem, 0, len(m.services)+2)
|
|
||||||
// Add action items at the top
|
|
||||||
items = append(items,
|
|
||||||
tui.ListItem{Title: "▶ Compose Up", Description: "docker compose up -d", Value: "up"},
|
|
||||||
tui.ListItem{Title: "■ Compose Down", Description: "docker compose down", Value: "down"},
|
|
||||||
)
|
|
||||||
for _, s := range m.services {
|
|
||||||
stateIcon := "●"
|
|
||||||
if s.State == "running" {
|
|
||||||
stateIcon = "▶"
|
|
||||||
}
|
|
||||||
items = append(items, tui.ListItem{
|
|
||||||
Title: fmt.Sprintf("%s %s", stateIcon, s.Name),
|
|
||||||
Description: fmt.Sprintf("Service: %s — %s", s.Service, s.Status),
|
|
||||||
Value: s,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = composeList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case composeActionMsg:
|
|
||||||
m.output = msg.output
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
|
||||||
}
|
|
||||||
m.state = composeList
|
|
||||||
return m, loadCompose
|
|
||||||
|
|
||||||
case composeLogsMsg:
|
|
||||||
m.output = msg.output
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
|
||||||
}
|
|
||||||
m.state = composeLogs
|
|
||||||
m.scrollOff = 0
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch m.state {
|
|
||||||
case composeList:
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = composeLoading
|
|
||||||
return m, tea.Batch(m.spinner.Init(), loadCompose)
|
|
||||||
case "l":
|
|
||||||
m.state = composeAction
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
output, err := ComposeLogs(100)
|
|
||||||
return composeLogsMsg{output: output, err: err}
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
switch v := item.Value.(type) {
|
|
||||||
case string:
|
|
||||||
m.state = composeAction
|
|
||||||
if v == "up" {
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
output, err := ComposeUp()
|
|
||||||
return composeActionMsg{output: output, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
output, err := ComposeDown()
|
|
||||||
return composeActionMsg{output: output, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case composeLogs:
|
|
||||||
switch msg.String() {
|
|
||||||
case "j", "down":
|
|
||||||
m.scrollOff++
|
|
||||||
case "k", "up":
|
|
||||||
if m.scrollOff > 0 {
|
|
||||||
m.scrollOff--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case composeLoading, composeAction:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = model.(tui.SpinnerModel)
|
|
||||||
case composeList:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.list.Update(msg)
|
|
||||||
m.list = model.(tui.ListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
|
||||||
func (m *ComposeModel) HandleBack() bool {
|
|
||||||
switch m.state {
|
|
||||||
case composeLogs:
|
|
||||||
m.state = composeList
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ComposeModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case composeLoading, composeAction:
|
|
||||||
return m.spinner.View()
|
|
||||||
case composeList:
|
|
||||||
if len(m.services) == 0 {
|
|
||||||
help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.")
|
|
||||||
return m.list.View() + "\n" + help
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh")
|
|
||||||
return m.list.View() + "\n" + help
|
|
||||||
case composeLogs:
|
|
||||||
return m.renderLogs()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ComposeModel) renderLogs() string {
|
|
||||||
lines := strings.Split(m.output, "\n")
|
|
||||||
if len(lines) == 0 {
|
|
||||||
lines = []string{"(empty)"}
|
|
||||||
}
|
|
||||||
maxLines := 20
|
|
||||||
if m.scrollOff >= len(lines) {
|
|
||||||
m.scrollOff = max(0, len(lines)-1)
|
|
||||||
}
|
|
||||||
end := min(m.scrollOff+maxLines, len(lines))
|
|
||||||
visible := lines[m.scrollOff:end]
|
|
||||||
|
|
||||||
header := m.styles.Header.Render("Compose Logs")
|
|
||||||
content := strings.Join(visible, "\n")
|
|
||||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
|
||||||
|
|
||||||
return header + "\n" + content + "\n" + help
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type containersState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
containersLoading containersState = iota
|
|
||||||
containersList
|
|
||||||
containersAction
|
|
||||||
containersLogs
|
|
||||||
)
|
|
||||||
|
|
||||||
type containersLoadedMsg []DockerContainer
|
|
||||||
type containersActionMsg struct{ output string; err error }
|
|
||||||
type containersLogsMsg struct{ output string; err error }
|
|
||||||
|
|
||||||
type ContainersModel struct {
|
|
||||||
state containersState
|
|
||||||
list tui.FilteredListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
containers []DockerContainer
|
|
||||||
output string
|
|
||||||
scrollOff int
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewContainersModel(styles tui.Styles) ContainersModel {
|
|
||||||
return ContainersModel{
|
|
||||||
state: containersLoading,
|
|
||||||
list: tui.NewFilteredList(nil, "Filter containers..."),
|
|
||||||
spinner: tui.NewSpinner("Loading containers..."),
|
|
||||||
styles: styles,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ContainersModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(
|
|
||||||
m.spinner.Init(),
|
|
||||||
loadContainers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadContainers() tea.Msg {
|
|
||||||
containers, err := ListContainers()
|
|
||||||
if err != nil {
|
|
||||||
return containersLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return containersLoadedMsg(containers)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case containersLoadedMsg:
|
|
||||||
m.containers = []DockerContainer(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.containers))
|
|
||||||
for i, c := range m.containers {
|
|
||||||
stateIcon := "●"
|
|
||||||
if c.State == "running" {
|
|
||||||
stateIcon = "▶"
|
|
||||||
} else if c.State == "exited" {
|
|
||||||
stateIcon = "■"
|
|
||||||
}
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: fmt.Sprintf("%s %s", stateIcon, c.Names),
|
|
||||||
Description: fmt.Sprintf("%s — %s", c.Image, c.Status),
|
|
||||||
Value: c,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = containersList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case containersActionMsg:
|
|
||||||
m.output = msg.output
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
|
||||||
}
|
|
||||||
m.state = containersList
|
|
||||||
// Refresh after action
|
|
||||||
return m, loadContainers
|
|
||||||
|
|
||||||
case containersLogsMsg:
|
|
||||||
m.output = msg.output
|
|
||||||
if msg.err != nil {
|
|
||||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
|
||||||
}
|
|
||||||
m.state = containersLogs
|
|
||||||
m.scrollOff = 0
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch m.state {
|
|
||||||
case containersList:
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = containersLoading
|
|
||||||
return m, tea.Batch(m.spinner.Init(), loadContainers)
|
|
||||||
case "enter":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
c := item.Value.(DockerContainer)
|
|
||||||
if c.State == "running" {
|
|
||||||
return m, stopContainerCmd(c.ID)
|
|
||||||
}
|
|
||||||
return m, startContainerCmd(c.ID)
|
|
||||||
}
|
|
||||||
case "l":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
c := item.Value.(DockerContainer)
|
|
||||||
m.state = containersAction
|
|
||||||
return m, logsContainerCmd(c.ID)
|
|
||||||
}
|
|
||||||
case "x":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
c := item.Value.(DockerContainer)
|
|
||||||
return m, restartContainerCmd(c.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case containersLogs:
|
|
||||||
switch msg.String() {
|
|
||||||
case "j", "down":
|
|
||||||
m.scrollOff++
|
|
||||||
case "k", "up":
|
|
||||||
if m.scrollOff > 0 {
|
|
||||||
m.scrollOff--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to sub-components
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case containersLoading:
|
|
||||||
var spinnerModel tea.Model
|
|
||||||
spinnerModel, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
|
||||||
case containersList:
|
|
||||||
var listModel tea.Model
|
|
||||||
listModel, cmd = m.list.Update(msg)
|
|
||||||
m.list = listModel.(tui.FilteredListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base (el caller debe salir).
|
|
||||||
func (m *ContainersModel) HandleBack() bool {
|
|
||||||
switch m.state {
|
|
||||||
case containersLogs:
|
|
||||||
m.state = containersList
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ContainersModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case containersLoading:
|
|
||||||
return m.spinner.View()
|
|
||||||
case containersList:
|
|
||||||
if len(m.containers) == 0 {
|
|
||||||
return m.styles.Muted.Render("No containers found. Press 'r' to refresh.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter")
|
|
||||||
return m.list.View() + "\n" + help
|
|
||||||
case containersAction:
|
|
||||||
return m.spinner.View()
|
|
||||||
case containersLogs:
|
|
||||||
return m.renderOutput()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ContainersModel) renderOutput() string {
|
|
||||||
lines := splitLines(m.output)
|
|
||||||
maxLines := 20
|
|
||||||
if m.scrollOff >= len(lines) {
|
|
||||||
m.scrollOff = max(0, len(lines)-1)
|
|
||||||
}
|
|
||||||
end := min(m.scrollOff+maxLines, len(lines))
|
|
||||||
visible := lines[m.scrollOff:end]
|
|
||||||
|
|
||||||
header := m.styles.Header.Render("Container Logs")
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
|
||||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
|
||||||
|
|
||||||
return header + "\n" + content + "\n" + help
|
|
||||||
}
|
|
||||||
|
|
||||||
func startContainerCmd(id string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
err := StartContainer(id)
|
|
||||||
return containersActionMsg{output: "Started " + id, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopContainerCmd(id string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
err := StopContainer(id)
|
|
||||||
return containersActionMsg{output: "Stopped " + id, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func restartContainerCmd(id string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
err := RestartContainer(id)
|
|
||||||
return containersActionMsg{output: "Restarted " + id, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func logsContainerCmd(id string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
output, err := ContainerLogs(id, 100)
|
|
||||||
return containersLogsMsg{output: output, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitLines(s string) []string {
|
|
||||||
if s == "" {
|
|
||||||
return []string{"(empty)"}
|
|
||||||
}
|
|
||||||
lines := strings.Split(s, "\n")
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return []string{"(empty)"}
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lucasdataproyects/devfactory/shell"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dockerTimeout = 15 * time.Second
|
|
||||||
|
|
||||||
// --- Containers ---
|
|
||||||
|
|
||||||
func ListContainers() ([]DockerContainer, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}")
|
|
||||||
stdout, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseJSONLines[DockerContainer](stdout.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func StartContainer(id string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func StopContainer(id string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func RestartContainer(id string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContainerLogs(id string, lines int) (string, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id)
|
|
||||||
out, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// docker logs writes to both stdout and stderr
|
|
||||||
output := out.Stdout
|
|
||||||
if out.Stderr != "" {
|
|
||||||
if output != "" {
|
|
||||||
output += "\n"
|
|
||||||
}
|
|
||||||
output += out.Stderr
|
|
||||||
}
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Images ---
|
|
||||||
|
|
||||||
func ListImages() ([]DockerImage, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}")
|
|
||||||
stdout, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseJSONLines[DockerImage](stdout.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PullImage(name string) (string, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name)
|
|
||||||
out, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.Stdout, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveImage(id string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Volumes ---
|
|
||||||
|
|
||||||
func ListVolumes() ([]DockerVolume, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}")
|
|
||||||
stdout, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseJSONLines[DockerVolume](stdout.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateVolume(name string) error {
|
|
||||||
args := []string{"volume", "create"}
|
|
||||||
if name != "" {
|
|
||||||
args = append(args, name)
|
|
||||||
}
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveVolume(name string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Networks ---
|
|
||||||
|
|
||||||
func ListNetworks() ([]DockerNetwork, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}")
|
|
||||||
stdout, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseJSONLines[DockerNetwork](stdout.Stdout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateNetwork(name string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveNetwork(name string) error {
|
|
||||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Compose ---
|
|
||||||
|
|
||||||
func ComposePS() ([]ComposeService, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json")
|
|
||||||
stdout, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// docker compose ps --format json returns a JSON array
|
|
||||||
var services []ComposeService
|
|
||||||
if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil {
|
|
||||||
// Try line-by-line as fallback
|
|
||||||
return parseJSONLines[ComposeService](stdout.Stdout)
|
|
||||||
}
|
|
||||||
return services, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ComposeUp() (string, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d")
|
|
||||||
out, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.Stdout + out.Stderr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ComposeDown() (string, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down")
|
|
||||||
out, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.Stdout + out.Stderr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ComposeLogs(lines int) (string, error) {
|
|
||||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines))
|
|
||||||
out, err := result.Both()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out.Stdout + out.Stderr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
func parseJSONLines[T any](s string) ([]T, error) {
|
|
||||||
var result []T
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var item T
|
|
||||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
return fmt.Sprintf("%d", n)
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type imagesState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
imagesLoading imagesState = iota
|
|
||||||
imagesList
|
|
||||||
imagesAction
|
|
||||||
)
|
|
||||||
|
|
||||||
type imagesLoadedMsg []DockerImage
|
|
||||||
type imagesActionMsg struct{ output string; err error }
|
|
||||||
|
|
||||||
type ImagesModel struct {
|
|
||||||
state imagesState
|
|
||||||
list tui.FilteredListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
images []DockerImage
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewImagesModel(styles tui.Styles) ImagesModel {
|
|
||||||
return ImagesModel{
|
|
||||||
state: imagesLoading,
|
|
||||||
list: tui.NewFilteredList(nil, "Filter images..."),
|
|
||||||
spinner: tui.NewSpinner("Loading images..."),
|
|
||||||
styles: styles,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ImagesModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.spinner.Init(), loadImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadImages() tea.Msg {
|
|
||||||
images, err := ListImages()
|
|
||||||
if err != nil {
|
|
||||||
return imagesLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return imagesLoadedMsg(images)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case imagesLoadedMsg:
|
|
||||||
m.images = []DockerImage(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.images))
|
|
||||||
for i, img := range m.images {
|
|
||||||
tag := img.Tag
|
|
||||||
if tag == "" {
|
|
||||||
tag = "latest"
|
|
||||||
}
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: fmt.Sprintf("%s:%s", img.Repository, tag),
|
|
||||||
Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]),
|
|
||||||
Value: img,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = imagesList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case imagesActionMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
|
||||||
}
|
|
||||||
m.state = imagesList
|
|
||||||
return m, loadImages
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if m.state == imagesList {
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = imagesLoading
|
|
||||||
return m, tea.Batch(m.spinner.Init(), loadImages)
|
|
||||||
case "d", "delete":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
img := item.Value.(DockerImage)
|
|
||||||
m.state = imagesAction
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
err := RemoveImage(img.ID)
|
|
||||||
return imagesActionMsg{output: "Removed", err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case imagesLoading, imagesAction:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = model.(tui.SpinnerModel)
|
|
||||||
case imagesList:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.list.Update(msg)
|
|
||||||
m.list = model.(tui.FilteredListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
|
||||||
func (m *ImagesModel) HandleBack() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ImagesModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case imagesLoading, imagesAction:
|
|
||||||
return m.spinner.View()
|
|
||||||
case imagesList:
|
|
||||||
if len(m.images) == 0 {
|
|
||||||
return m.styles.Muted.Render("No images found. Press 'r' to refresh.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter")
|
|
||||||
view := m.list.View() + "\n" + help
|
|
||||||
if m.err != nil {
|
|
||||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
// Navigation key constants.
|
|
||||||
const (
|
|
||||||
KeyQuit = "ctrl+c"
|
|
||||||
KeyEsc = "esc"
|
|
||||||
KeyBack = "0"
|
|
||||||
KeyTab = "tab"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsBack returns true if the key should trigger back navigation.
|
|
||||||
func IsBack(key string) bool {
|
|
||||||
return key == KeyEsc || key == KeyBack
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type networksState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
networksLoading networksState = iota
|
|
||||||
networksList
|
|
||||||
networksAction
|
|
||||||
)
|
|
||||||
|
|
||||||
type networksLoadedMsg []DockerNetwork
|
|
||||||
type networksActionMsg struct{ output string; err error }
|
|
||||||
|
|
||||||
type NetworksModel struct {
|
|
||||||
state networksState
|
|
||||||
list tui.ListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
networks []DockerNetwork
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNetworksModel(styles tui.Styles) NetworksModel {
|
|
||||||
return NetworksModel{
|
|
||||||
state: networksLoading,
|
|
||||||
list: tui.NewList(nil),
|
|
||||||
spinner: tui.NewSpinner("Loading networks..."),
|
|
||||||
styles: styles,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m NetworksModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.spinner.Init(), loadNetworks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadNetworks() tea.Msg {
|
|
||||||
networks, err := ListNetworks()
|
|
||||||
if err != nil {
|
|
||||||
return networksLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return networksLoadedMsg(networks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case networksLoadedMsg:
|
|
||||||
m.networks = []DockerNetwork(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.networks))
|
|
||||||
for i, n := range m.networks {
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: n.Name,
|
|
||||||
Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope),
|
|
||||||
Value: n,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = networksList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case networksActionMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
|
||||||
}
|
|
||||||
m.state = networksList
|
|
||||||
return m, loadNetworks
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if m.state == networksList {
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = networksLoading
|
|
||||||
return m, tea.Batch(m.spinner.Init(), loadNetworks)
|
|
||||||
case "d", "delete":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
net := item.Value.(DockerNetwork)
|
|
||||||
m.state = networksAction
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
err := RemoveNetwork(net.Name)
|
|
||||||
return networksActionMsg{output: "Removed", err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case networksLoading, networksAction:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = model.(tui.SpinnerModel)
|
|
||||||
case networksList:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.list.Update(msg)
|
|
||||||
m.list = model.(tui.ListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
|
||||||
func (m *NetworksModel) HandleBack() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m NetworksModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case networksLoading, networksAction:
|
|
||||||
return m.spinner.View()
|
|
||||||
case networksList:
|
|
||||||
if len(m.networks) == 0 {
|
|
||||||
return m.styles.Muted.Render("No networks found. Press 'r' to refresh.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
|
||||||
view := m.list.View() + "\n" + help
|
|
||||||
if m.err != nil {
|
|
||||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
// DockerContainer represents a container from docker ps --format json.
|
|
||||||
type DockerContainer struct {
|
|
||||||
ID string `json:"ID"`
|
|
||||||
Names string `json:"Names"`
|
|
||||||
Image string `json:"Image"`
|
|
||||||
Status string `json:"Status"`
|
|
||||||
State string `json:"State"`
|
|
||||||
Ports string `json:"Ports"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DockerImage represents an image from docker image ls --format json.
|
|
||||||
type DockerImage struct {
|
|
||||||
ID string `json:"ID"`
|
|
||||||
Repository string `json:"Repository"`
|
|
||||||
Tag string `json:"Tag"`
|
|
||||||
Size string `json:"Size"`
|
|
||||||
CreatedAt string `json:"CreatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DockerVolume represents a volume from docker volume ls --format json.
|
|
||||||
type DockerVolume struct {
|
|
||||||
Name string `json:"Name"`
|
|
||||||
Driver string `json:"Driver"`
|
|
||||||
Mountpoint string `json:"Mountpoint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DockerNetwork represents a network from docker network ls --format json.
|
|
||||||
type DockerNetwork struct {
|
|
||||||
ID string `json:"ID"`
|
|
||||||
Name string `json:"Name"`
|
|
||||||
Driver string `json:"Driver"`
|
|
||||||
Scope string `json:"Scope"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComposeService represents a compose service from docker compose ps --format json.
|
|
||||||
type ComposeService struct {
|
|
||||||
ID string `json:"ID"`
|
|
||||||
Name string `json:"Name"`
|
|
||||||
Service string `json:"Service"`
|
|
||||||
State string `json:"State"`
|
|
||||||
Status string `json:"Status"`
|
|
||||||
Ports string `json:"Ports"`
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type volumesState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
volumesLoading volumesState = iota
|
|
||||||
volumesList
|
|
||||||
volumesAction
|
|
||||||
)
|
|
||||||
|
|
||||||
type volumesLoadedMsg []DockerVolume
|
|
||||||
type volumesActionMsg struct{ output string; err error }
|
|
||||||
|
|
||||||
type VolumesModel struct {
|
|
||||||
state volumesState
|
|
||||||
list tui.ListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
volumes []DockerVolume
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewVolumesModel(styles tui.Styles) VolumesModel {
|
|
||||||
return VolumesModel{
|
|
||||||
state: volumesLoading,
|
|
||||||
list: tui.NewList(nil),
|
|
||||||
spinner: tui.NewSpinner("Loading volumes..."),
|
|
||||||
styles: styles,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m VolumesModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.spinner.Init(), loadVolumes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadVolumes() tea.Msg {
|
|
||||||
volumes, err := ListVolumes()
|
|
||||||
if err != nil {
|
|
||||||
return volumesLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return volumesLoadedMsg(volumes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case volumesLoadedMsg:
|
|
||||||
m.volumes = []DockerVolume(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.volumes))
|
|
||||||
for i, v := range m.volumes {
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: v.Name,
|
|
||||||
Description: fmt.Sprintf("Driver: %s", v.Driver),
|
|
||||||
Value: v,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = volumesList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case volumesActionMsg:
|
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
|
||||||
}
|
|
||||||
m.state = volumesList
|
|
||||||
return m, loadVolumes
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if m.state == volumesList {
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = volumesLoading
|
|
||||||
return m, tea.Batch(m.spinner.Init(), loadVolumes)
|
|
||||||
case "d", "delete":
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
vol := item.Value.(DockerVolume)
|
|
||||||
m.state = volumesAction
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
err := RemoveVolume(vol.Name)
|
|
||||||
return volumesActionMsg{output: "Removed", err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case volumesLoading, volumesAction:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = model.(tui.SpinnerModel)
|
|
||||||
case volumesList:
|
|
||||||
var model tea.Model
|
|
||||||
model, cmd = m.list.Update(msg)
|
|
||||||
m.list = model.(tui.ListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
|
||||||
func (m *VolumesModel) HandleBack() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m VolumesModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case volumesLoading, volumesAction:
|
|
||||||
return m.spinner.View()
|
|
||||||
case volumesList:
|
|
||||||
if len(m.volumes) == 0 {
|
|
||||||
return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
|
||||||
view := m.list.View() + "\n" + help
|
|
||||||
if m.err != nil {
|
|
||||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
ops "fn-registry/fn_operations"
|
|
||||||
"fn-registry/registry"
|
|
||||||
"pipeline-launcher/config"
|
|
||||||
"pipeline-launcher/views"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
// View identifies which tab is active.
|
|
||||||
type View int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ViewPipelines View = iota
|
|
||||||
ViewHistory
|
|
||||||
)
|
|
||||||
|
|
||||||
var tabNames = []string{"Pipelines", "History"}
|
|
||||||
|
|
||||||
// Model is the top-level TUI model with two tabs.
|
|
||||||
type Model struct {
|
|
||||||
tui.BaseModel
|
|
||||||
activeTab int
|
|
||||||
pipelines views.PipelinesModel
|
|
||||||
history views.HistoryModel
|
|
||||||
ready bool
|
|
||||||
registryDB *registry.DB
|
|
||||||
opsDB *ops.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates the Model, opening both databases.
|
|
||||||
func New(cfg config.Config) (Model, error) {
|
|
||||||
regDB, err := registry.Open(cfg.RegistryDB)
|
|
||||||
if err != nil {
|
|
||||||
return Model{}, fmt.Errorf("opening registry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opsDB, err := ops.Open(cfg.OperationsDB)
|
|
||||||
if err != nil {
|
|
||||||
regDB.Close()
|
|
||||||
return Model{}, fmt.Errorf("opening operations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build pipeline name map for history view
|
|
||||||
fns, _ := regDB.SearchFunctions("", registry.KindPipeline, "", "", "")
|
|
||||||
names := make(map[string]string, len(fns))
|
|
||||||
for _, f := range fns {
|
|
||||||
names[f.ID] = f.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
styles := tui.DarkStyles()
|
|
||||||
|
|
||||||
return Model{
|
|
||||||
BaseModel: tui.NewBaseModel().WithStyles(styles),
|
|
||||||
pipelines: views.NewPipelinesModel(styles, regDB, opsDB, cfg.RegistryRoot),
|
|
||||||
history: views.NewHistoryModel(styles, opsDB, names),
|
|
||||||
registryDB: regDB,
|
|
||||||
opsDB: opsDB,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes both database connections.
|
|
||||||
func (m Model) Close() {
|
|
||||||
if m.registryDB != nil {
|
|
||||||
m.registryDB.Close()
|
|
||||||
}
|
|
||||||
if m.opsDB != nil {
|
|
||||||
m.opsDB.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
return m.pipelines.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case views.KeyQuit:
|
|
||||||
return m, tea.Quit
|
|
||||||
case "q":
|
|
||||||
updated, atBase := m.handleBack()
|
|
||||||
if atBase {
|
|
||||||
return updated, tea.Quit
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
case views.KeyEsc, views.KeyBack:
|
|
||||||
updated, atBase := m.handleBack()
|
|
||||||
if atBase {
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
case views.KeyTab:
|
|
||||||
m.activeTab = (m.activeTab + 1) % len(tabNames)
|
|
||||||
return m, m.initActiveView()
|
|
||||||
case "shift+tab":
|
|
||||||
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
|
|
||||||
return m, m.initActiveView()
|
|
||||||
}
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.HandleWindowSize(msg)
|
|
||||||
m.ready = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewPipelines:
|
|
||||||
m.pipelines, cmd = m.pipelines.Update(msg)
|
|
||||||
case ViewHistory:
|
|
||||||
m.history, cmd = m.history.Update(msg)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if !m.ready {
|
|
||||||
return "Loading..."
|
|
||||||
}
|
|
||||||
|
|
||||||
tabs := m.renderTabs()
|
|
||||||
|
|
||||||
var content string
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewPipelines:
|
|
||||||
content = m.pipelines.View()
|
|
||||||
case ViewHistory:
|
|
||||||
content = m.history.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
|
||||||
tabs,
|
|
||||||
"",
|
|
||||||
content,
|
|
||||||
"",
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderTabs() string {
|
|
||||||
var tabs []string
|
|
||||||
for i, name := range tabNames {
|
|
||||||
if i == m.activeTab {
|
|
||||||
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
|
|
||||||
} else {
|
|
||||||
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
|
||||||
return m.Styles.Header.Render("Pipeline Launcher") + " " + row
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) handleBack() (Model, bool) {
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewPipelines:
|
|
||||||
atBase := m.pipelines.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
case ViewHistory:
|
|
||||||
atBase := m.history.HandleBack()
|
|
||||||
return m, atBase
|
|
||||||
}
|
|
||||||
return m, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) initActiveView() tea.Cmd {
|
|
||||||
switch View(m.activeTab) {
|
|
||||||
case ViewPipelines:
|
|
||||||
return m.pipelines.Init()
|
|
||||||
case ViewHistory:
|
|
||||||
return m.history.Init()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds paths to databases.
|
|
||||||
type Config struct {
|
|
||||||
RegistryDB string // Path to registry.db
|
|
||||||
OperationsDB string // Path to operations.db
|
|
||||||
RegistryRoot string // Root directory of the registry (for resolving file paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default returns a Config resolved from environment or sensible defaults.
|
|
||||||
func Default() Config {
|
|
||||||
root := os.Getenv("FN_REGISTRY_ROOT")
|
|
||||||
if root == "" {
|
|
||||||
root = "."
|
|
||||||
}
|
|
||||||
|
|
||||||
return Config{
|
|
||||||
RegistryDB: filepath.Join(root, "registry.db"),
|
|
||||||
OperationsDB: filepath.Join(root, "apps", "pipeline_launcher", "operations.db"),
|
|
||||||
RegistryRoot: root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
module pipeline-launcher
|
|
||||||
|
|
||||||
go 1.22.2
|
|
||||||
|
|
||||||
require (
|
|
||||||
fn-registry v0.0.0
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1
|
|
||||||
github.com/lucasdataproyects/devfactory v0.0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.6 // indirect
|
|
||||||
golang.org/x/sync v0.4.0 // indirect
|
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
|
||||||
golang.org/x/term v0.6.0 // indirect
|
|
||||||
golang.org/x/text v0.13.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace (
|
|
||||||
fn-registry => /home/lucas/fn_registry
|
|
||||||
github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
|
|
||||||
)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
|
|
||||||
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
|
|
||||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
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/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
|
||||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
|
||||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
|
||||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
|
||||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
|
||||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"pipeline-launcher/app"
|
|
||||||
"pipeline-launcher/config"
|
|
||||||
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg := config.Default()
|
|
||||||
|
|
||||||
model, err := app.New(cfg)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer model.Close()
|
|
||||||
|
|
||||||
result := tui.RunFullscreen(model)
|
|
||||||
|
|
||||||
if result.IsErr() {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
ops "fn-registry/fn_operations"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type historyState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
historyLoading historyState = iota
|
|
||||||
historyList
|
|
||||||
historyDetail
|
|
||||||
)
|
|
||||||
|
|
||||||
type historyLoadedMsg []ops.Execution
|
|
||||||
|
|
||||||
// HistoryModel shows execution history.
|
|
||||||
type HistoryModel struct {
|
|
||||||
state historyState
|
|
||||||
list tui.FilteredListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
executions []ops.Execution
|
|
||||||
detail string
|
|
||||||
scrollOff int
|
|
||||||
opsDB *ops.DB
|
|
||||||
pipelineNames map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHistoryModel creates a new history view.
|
|
||||||
func NewHistoryModel(styles tui.Styles, opsDB *ops.DB, names map[string]string) HistoryModel {
|
|
||||||
return HistoryModel{
|
|
||||||
state: historyLoading,
|
|
||||||
list: tui.NewFilteredList(nil, "Filter executions..."),
|
|
||||||
spinner: tui.NewSpinner("Loading history..."),
|
|
||||||
styles: styles,
|
|
||||||
opsDB: opsDB,
|
|
||||||
pipelineNames: names,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m HistoryModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(
|
|
||||||
m.spinner.Init(),
|
|
||||||
m.loadHistory(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m HistoryModel) loadHistory() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
execs, err := m.opsDB.ListExecutions("", "", "")
|
|
||||||
if err != nil {
|
|
||||||
return historyLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
return historyLoadedMsg(execs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case historyLoadedMsg:
|
|
||||||
m.executions = []ops.Execution(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.executions))
|
|
||||||
for i, e := range m.executions {
|
|
||||||
icon := "●"
|
|
||||||
switch e.Status {
|
|
||||||
case ops.ExecSuccess:
|
|
||||||
icon = "✓"
|
|
||||||
case ops.ExecFailure:
|
|
||||||
icon = "✗"
|
|
||||||
case ops.ExecPartial:
|
|
||||||
icon = "~"
|
|
||||||
}
|
|
||||||
name := e.PipelineID
|
|
||||||
if n, ok := m.pipelineNames[e.PipelineID]; ok {
|
|
||||||
name = n
|
|
||||||
}
|
|
||||||
dur := ""
|
|
||||||
if e.DurationMs != nil {
|
|
||||||
dur = fmt.Sprintf("%dms", *e.DurationMs)
|
|
||||||
}
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: fmt.Sprintf("%s %s", icon, name),
|
|
||||||
Description: fmt.Sprintf("%s — %s — %s", string(e.Status), dur, e.StartedAt.Format("2006-01-02 15:04:05")),
|
|
||||||
Value: e,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = historyList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch m.state {
|
|
||||||
case historyList:
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = historyLoading
|
|
||||||
m.spinner = tui.NewSpinner("Loading history...")
|
|
||||||
return m, tea.Batch(m.spinner.Init(), m.loadHistory())
|
|
||||||
case "enter":
|
|
||||||
// Delegate enter to list first so it selects the cursor item
|
|
||||||
updated, _ := m.list.Update(msg)
|
|
||||||
m.list = updated.(tui.FilteredListModel)
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
e := item.Value.(ops.Execution)
|
|
||||||
m.detail = formatExecution(e)
|
|
||||||
m.state = historyDetail
|
|
||||||
m.scrollOff = 0
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case historyDetail:
|
|
||||||
switch msg.String() {
|
|
||||||
case "j", "down":
|
|
||||||
m.scrollOff++
|
|
||||||
case "k", "up":
|
|
||||||
if m.scrollOff > 0 {
|
|
||||||
m.scrollOff--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to sub-components
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case historyLoading:
|
|
||||||
var spinnerModel tea.Model
|
|
||||||
spinnerModel, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
|
||||||
case historyList:
|
|
||||||
var listModel tea.Model
|
|
||||||
listModel, cmd = m.list.Update(msg)
|
|
||||||
m.list = listModel.(tui.FilteredListModel)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
|
||||||
func (m *HistoryModel) HandleBack() bool {
|
|
||||||
switch m.state {
|
|
||||||
case historyDetail:
|
|
||||||
m.state = historyList
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m HistoryModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case historyLoading:
|
|
||||||
return m.spinner.View()
|
|
||||||
case historyList:
|
|
||||||
if len(m.executions) == 0 {
|
|
||||||
return m.styles.Muted.Render("No executions found. Launch a pipeline first.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" Enter: details │ r: refresh │ /: filter")
|
|
||||||
return m.list.View() + "\n" + help
|
|
||||||
case historyDetail:
|
|
||||||
return m.renderDetail()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m HistoryModel) renderDetail() string {
|
|
||||||
lines := splitLines(m.detail)
|
|
||||||
maxLines := 20
|
|
||||||
if m.scrollOff >= len(lines) {
|
|
||||||
m.scrollOff = max(0, len(lines)-1)
|
|
||||||
}
|
|
||||||
end := min(m.scrollOff+maxLines, len(lines))
|
|
||||||
visible := lines[m.scrollOff:end]
|
|
||||||
|
|
||||||
header := m.styles.Header.Render("Execution Detail")
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
|
||||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
|
||||||
|
|
||||||
return header + "\n" + content + "\n" + help
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatExecution(e ops.Execution) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("ID: %s\n", e.ID))
|
|
||||||
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", e.PipelineID))
|
|
||||||
sb.WriteString(fmt.Sprintf("Status: %s\n", e.Status))
|
|
||||||
sb.WriteString(fmt.Sprintf("Started: %s\n", e.StartedAt.Format("2006-01-02 15:04:05")))
|
|
||||||
if e.EndedAt != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Ended: %s\n", e.EndedAt.Format("2006-01-02 15:04:05")))
|
|
||||||
}
|
|
||||||
if e.DurationMs != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Duration: %dms\n", *e.DurationMs))
|
|
||||||
}
|
|
||||||
if e.RecordsIn != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Records In: %d\n", *e.RecordsIn))
|
|
||||||
}
|
|
||||||
if e.RecordsOut != nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Records Out: %d\n", *e.RecordsOut))
|
|
||||||
}
|
|
||||||
if e.Error != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n--- Error ---\n%s\n", e.Error))
|
|
||||||
}
|
|
||||||
if len(e.Metrics) > 0 {
|
|
||||||
sb.WriteString("\n--- Metrics ---\n")
|
|
||||||
b, _ := json.MarshalIndent(e.Metrics, "", " ")
|
|
||||||
sb.WriteString(string(b))
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
// Navigation key constants.
|
|
||||||
const (
|
|
||||||
KeyQuit = "ctrl+c"
|
|
||||||
KeyEsc = "esc"
|
|
||||||
KeyBack = "0"
|
|
||||||
KeyTab = "tab"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsBack returns true if the key should trigger back navigation.
|
|
||||||
func IsBack(key string) bool {
|
|
||||||
return key == KeyEsc || key == KeyBack
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
ops "fn-registry/fn_operations"
|
|
||||||
"fn-registry/registry"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/lucasdataproyects/devfactory/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pipelinesState int
|
|
||||||
|
|
||||||
const (
|
|
||||||
pipelinesLoading pipelinesState = iota
|
|
||||||
pipelinesList
|
|
||||||
pipelinesArgs
|
|
||||||
pipelinesRunning
|
|
||||||
pipelinesOutput
|
|
||||||
)
|
|
||||||
|
|
||||||
type pipelinesLoadedMsg []registry.Function
|
|
||||||
type pipelineFinishedMsg RunResult
|
|
||||||
type pipelineFlagsMsg []PipelineFlag
|
|
||||||
|
|
||||||
// PipelinesModel lists and launches pipelines.
|
|
||||||
type PipelinesModel struct {
|
|
||||||
state pipelinesState
|
|
||||||
list tui.FilteredListModel
|
|
||||||
spinner tui.SpinnerModel
|
|
||||||
styles tui.Styles
|
|
||||||
pipelines []registry.Function
|
|
||||||
selectedFn *registry.Function
|
|
||||||
flags []PipelineFlag
|
|
||||||
inputs []textinput.Model
|
|
||||||
focusIdx int
|
|
||||||
output string
|
|
||||||
lastResult *RunResult
|
|
||||||
scrollOff int
|
|
||||||
err error
|
|
||||||
registryDB *registry.DB
|
|
||||||
opsDB *ops.DB
|
|
||||||
registryRoot string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPipelinesModel creates a new pipelines view.
|
|
||||||
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
|
|
||||||
return PipelinesModel{
|
|
||||||
state: pipelinesLoading,
|
|
||||||
list: tui.NewFilteredList(nil, "Filter pipelines..."),
|
|
||||||
spinner: tui.NewSpinner("Loading pipelines..."),
|
|
||||||
styles: styles,
|
|
||||||
registryDB: regDB,
|
|
||||||
opsDB: opsDB,
|
|
||||||
registryRoot: root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(
|
|
||||||
m.spinner.Init(),
|
|
||||||
m.loadPipelines(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) loadPipelines() tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
|
|
||||||
if err != nil {
|
|
||||||
return pipelinesLoadedMsg(nil)
|
|
||||||
}
|
|
||||||
// Only show pipelines tagged with "launcher"
|
|
||||||
var launchable []registry.Function
|
|
||||||
for _, f := range fns {
|
|
||||||
for _, t := range f.Tags {
|
|
||||||
if t == "launcher" {
|
|
||||||
launchable = append(launchable, f)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pipelinesLoadedMsg(launchable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildInputs creates a textinput for each flag, pre-filled with defaults.
|
|
||||||
func (m *PipelinesModel) buildInputs() tea.Cmd {
|
|
||||||
m.inputs = make([]textinput.Model, len(m.flags))
|
|
||||||
for i, f := range m.flags {
|
|
||||||
ti := textinput.New()
|
|
||||||
ti.CharLimit = 256
|
|
||||||
ti.Width = 40
|
|
||||||
if f.Default != "" {
|
|
||||||
ti.SetValue(f.Default)
|
|
||||||
}
|
|
||||||
if f.Required {
|
|
||||||
ti.Placeholder = "(requerido)"
|
|
||||||
}
|
|
||||||
m.inputs[i] = ti
|
|
||||||
}
|
|
||||||
m.focusIdx = 0
|
|
||||||
if len(m.inputs) > 0 {
|
|
||||||
m.inputs[0].Focus()
|
|
||||||
return textinput.Blink
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *PipelinesModel) focusInput(idx int) tea.Cmd {
|
|
||||||
if idx < 0 || idx >= len(m.inputs) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range m.inputs {
|
|
||||||
m.inputs[i].Blur()
|
|
||||||
}
|
|
||||||
m.focusIdx = idx
|
|
||||||
m.inputs[idx].Focus()
|
|
||||||
return textinput.Blink
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectArgs builds CLI args from the form inputs.
|
|
||||||
func (m PipelinesModel) collectArgs() []string {
|
|
||||||
var args []string
|
|
||||||
for i, f := range m.flags {
|
|
||||||
val := strings.TrimSpace(m.inputs[i].Value())
|
|
||||||
if val != "" {
|
|
||||||
args = append(args, "--"+f.Name, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case pipelinesLoadedMsg:
|
|
||||||
m.pipelines = []registry.Function(msg)
|
|
||||||
items := make([]tui.ListItem, len(m.pipelines))
|
|
||||||
for i, p := range m.pipelines {
|
|
||||||
items[i] = tui.ListItem{
|
|
||||||
Title: p.Name,
|
|
||||||
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
|
|
||||||
Value: p,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.state = pipelinesList
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case pipelineFlagsMsg:
|
|
||||||
m.flags = []PipelineFlag(msg)
|
|
||||||
cmd := m.buildInputs()
|
|
||||||
return m, cmd
|
|
||||||
|
|
||||||
case pipelineFinishedMsg:
|
|
||||||
result := RunResult(msg)
|
|
||||||
m.lastResult = &result
|
|
||||||
var sb strings.Builder
|
|
||||||
if result.Status == ops.ExecSuccess {
|
|
||||||
sb.WriteString("[OK] ")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("[FAIL] ")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID)
|
|
||||||
fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID)
|
|
||||||
fmt.Fprintf(&sb, "Duration: %dms\n", result.DurationMs)
|
|
||||||
sb.WriteString("\n--- stdout ---\n")
|
|
||||||
if result.Stdout != "" {
|
|
||||||
sb.WriteString(result.Stdout)
|
|
||||||
} else {
|
|
||||||
sb.WriteString("(empty)")
|
|
||||||
}
|
|
||||||
if result.Stderr != "" {
|
|
||||||
sb.WriteString("\n--- stderr ---\n")
|
|
||||||
sb.WriteString(result.Stderr)
|
|
||||||
}
|
|
||||||
if result.Err != nil {
|
|
||||||
fmt.Fprintf(&sb, "\n--- error ---\n%v", result.Err)
|
|
||||||
}
|
|
||||||
m.output = sb.String()
|
|
||||||
m.state = pipelinesOutput
|
|
||||||
m.scrollOff = 0
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch m.state {
|
|
||||||
case pipelinesList:
|
|
||||||
switch msg.String() {
|
|
||||||
case "r":
|
|
||||||
m.state = pipelinesLoading
|
|
||||||
m.spinner = tui.NewSpinner("Loading pipelines...")
|
|
||||||
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
|
|
||||||
case "enter":
|
|
||||||
updated, _ := m.list.Update(msg)
|
|
||||||
m.list = updated.(tui.FilteredListModel)
|
|
||||||
if item := m.list.SelectedItem(); item != nil {
|
|
||||||
fn := item.Value.(registry.Function)
|
|
||||||
m.selectedFn = &fn
|
|
||||||
m.flags = nil
|
|
||||||
m.inputs = nil
|
|
||||||
m.state = pipelinesArgs
|
|
||||||
root := m.registryRoot
|
|
||||||
fnCopy := fn
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case pipelinesArgs:
|
|
||||||
switch msg.String() {
|
|
||||||
case "tab", "down":
|
|
||||||
cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1))
|
|
||||||
return m, cmd
|
|
||||||
case "shift+tab", "up":
|
|
||||||
idx := m.focusIdx - 1
|
|
||||||
if idx < 0 {
|
|
||||||
idx = max(len(m.inputs)-1, 0)
|
|
||||||
}
|
|
||||||
cmd := m.focusInput(idx)
|
|
||||||
return m, cmd
|
|
||||||
case "ctrl+enter", "ctrl+s":
|
|
||||||
args := m.collectArgs()
|
|
||||||
m.state = pipelinesRunning
|
|
||||||
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name))
|
|
||||||
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args))
|
|
||||||
case "esc":
|
|
||||||
m.state = pipelinesList
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case pipelinesOutput:
|
|
||||||
switch msg.String() {
|
|
||||||
case "j", "down":
|
|
||||||
m.scrollOff++
|
|
||||||
case "k", "up":
|
|
||||||
if m.scrollOff > 0 {
|
|
||||||
m.scrollOff--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to sub-components
|
|
||||||
var cmd tea.Cmd
|
|
||||||
switch m.state {
|
|
||||||
case pipelinesLoading, pipelinesRunning:
|
|
||||||
var spinnerModel tea.Model
|
|
||||||
spinnerModel, cmd = m.spinner.Update(msg)
|
|
||||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
|
||||||
case pipelinesList:
|
|
||||||
var listModel tea.Model
|
|
||||||
listModel, cmd = m.list.Update(msg)
|
|
||||||
m.list = listModel.(tui.FilteredListModel)
|
|
||||||
case pipelinesArgs:
|
|
||||||
if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) {
|
|
||||||
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd {
|
|
||||||
regRoot := m.registryRoot
|
|
||||||
opsDB := m.opsDB
|
|
||||||
fnCopy := *fn
|
|
||||||
return func() tea.Msg {
|
|
||||||
result := RunPipeline(&fnCopy, regRoot, opsDB, args)
|
|
||||||
return pipelineFinishedMsg(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
|
||||||
func (m *PipelinesModel) HandleBack() bool {
|
|
||||||
switch m.state {
|
|
||||||
case pipelinesArgs:
|
|
||||||
m.state = pipelinesList
|
|
||||||
return false
|
|
||||||
case pipelinesOutput:
|
|
||||||
m.state = pipelinesList
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case pipelinesLoading:
|
|
||||||
return m.spinner.View()
|
|
||||||
case pipelinesList:
|
|
||||||
if len(m.pipelines) == 0 {
|
|
||||||
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
|
|
||||||
}
|
|
||||||
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
|
|
||||||
return m.list.View() + "\n" + help
|
|
||||||
case pipelinesArgs:
|
|
||||||
return m.renderArgsForm()
|
|
||||||
case pipelinesRunning:
|
|
||||||
return m.spinner.View()
|
|
||||||
case pipelinesOutput:
|
|
||||||
return m.renderOutput()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) renderArgsForm() string {
|
|
||||||
header := m.styles.Header.Render(m.selectedFn.Name)
|
|
||||||
|
|
||||||
var parts []string
|
|
||||||
parts = append(parts, header, "")
|
|
||||||
|
|
||||||
if len(m.flags) == 0 {
|
|
||||||
parts = append(parts, m.styles.Muted.Render(" Loading flags..."))
|
|
||||||
} else if len(m.inputs) == 0 {
|
|
||||||
parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run."))
|
|
||||||
} else {
|
|
||||||
for i, f := range m.flags {
|
|
||||||
marker := " "
|
|
||||||
if f.Required {
|
|
||||||
marker = m.styles.Error.Render("* ")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := fmt.Sprintf("--%-16s", f.Name)
|
|
||||||
cursor := " "
|
|
||||||
if i == m.focusIdx {
|
|
||||||
cursor = m.styles.Info.Render("> ")
|
|
||||||
}
|
|
||||||
|
|
||||||
label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name))
|
|
||||||
input := m.inputs[i].View()
|
|
||||||
|
|
||||||
desc := f.Desc
|
|
||||||
if f.Default != "" {
|
|
||||||
desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default))
|
|
||||||
}
|
|
||||||
|
|
||||||
parts = append(parts, label+input)
|
|
||||||
parts = append(parts, " "+m.styles.Muted.Render(desc))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parts = append(parts, "")
|
|
||||||
parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel"))
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PipelinesModel) renderOutput() string {
|
|
||||||
lines := splitLines(m.output)
|
|
||||||
maxLines := 20
|
|
||||||
if m.scrollOff >= len(lines) {
|
|
||||||
m.scrollOff = max(0, len(lines)-1)
|
|
||||||
}
|
|
||||||
end := min(m.scrollOff+maxLines, len(lines))
|
|
||||||
visible := lines[m.scrollOff:end]
|
|
||||||
|
|
||||||
header := m.styles.Header.Render("Pipeline Output")
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
|
||||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
|
||||||
|
|
||||||
return header + "\n" + content + "\n" + help
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitLines(s string) []string {
|
|
||||||
if s == "" {
|
|
||||||
return []string{"(empty)"}
|
|
||||||
}
|
|
||||||
lines := strings.Split(s, "\n")
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return []string{"(empty)"}
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n-3] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
ops "fn-registry/fn_operations"
|
|
||||||
"fn-registry/registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PipelineFlag describes a CLI flag parsed from -help output.
|
|
||||||
type PipelineFlag struct {
|
|
||||||
Name string // e.g. "project"
|
|
||||||
Type string // e.g. "string"
|
|
||||||
Desc string // description text
|
|
||||||
Default string // default value, empty if none
|
|
||||||
Required bool // true if no default
|
|
||||||
}
|
|
||||||
|
|
||||||
var flagLineRe = regexp.MustCompile(`^\s+-(\S+)\s+(\S+)$`)
|
|
||||||
var defaultRe = regexp.MustCompile(`\(default "(.*)"\)`)
|
|
||||||
|
|
||||||
// GetPipelineFlags runs `go run . -help` and parses the flag output.
|
|
||||||
func GetPipelineFlags(fn *registry.Function, registryRoot string) []PipelineFlag {
|
|
||||||
absPath := filepath.Join(registryRoot, fn.FilePath)
|
|
||||||
dir := filepath.Dir(absPath)
|
|
||||||
|
|
||||||
cmd := exec.Command("go", "run", ".", "-help")
|
|
||||||
cmd.Dir = dir
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
cmd.Run() // -help exits with code 2, ignore error
|
|
||||||
|
|
||||||
return parseFlags(stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlags(output string) []PipelineFlag {
|
|
||||||
var flags []PipelineFlag
|
|
||||||
lines := strings.Split(output, "\n")
|
|
||||||
|
|
||||||
for i := 0; i < len(lines); i++ {
|
|
||||||
m := flagLineRe.FindStringSubmatch(lines[i])
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f := PipelineFlag{Name: m[1], Type: m[2]}
|
|
||||||
|
|
||||||
// Next line is the description
|
|
||||||
if i+1 < len(lines) {
|
|
||||||
desc := strings.TrimSpace(lines[i+1])
|
|
||||||
if dm := defaultRe.FindStringSubmatch(desc); dm != nil {
|
|
||||||
f.Default = dm[1]
|
|
||||||
f.Desc = strings.TrimSpace(defaultRe.ReplaceAllString(desc, ""))
|
|
||||||
} else {
|
|
||||||
f.Desc = desc
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Required = f.Default == "" && !strings.Contains(strings.ToLower(f.Desc), "opcional")
|
|
||||||
flags = append(flags, f)
|
|
||||||
}
|
|
||||||
return flags
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunResult holds the outcome of a pipeline execution.
|
|
||||||
type RunResult struct {
|
|
||||||
Stdout string
|
|
||||||
Stderr string
|
|
||||||
ExecID string
|
|
||||||
PipelineID string
|
|
||||||
Status ops.ExecutionStatus
|
|
||||||
DurationMs int64
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunPipeline executes a pipeline as a subprocess and records the execution.
|
|
||||||
func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB, args []string) RunResult {
|
|
||||||
absPath := filepath.Join(registryRoot, fn.FilePath)
|
|
||||||
dir := filepath.Dir(absPath)
|
|
||||||
|
|
||||||
startedAt := time.Now().UTC()
|
|
||||||
|
|
||||||
cmdArgs := append([]string{"run", "."}, args...)
|
|
||||||
cmd := exec.Command("go", cmdArgs...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
endedAt := time.Now().UTC()
|
|
||||||
|
|
||||||
status := ops.ExecSuccess
|
|
||||||
var execErr string
|
|
||||||
if err != nil {
|
|
||||||
status = ops.ExecFailure
|
|
||||||
execErr = err.Error()
|
|
||||||
if stderr.Len() > 0 {
|
|
||||||
execErr = stderr.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
execID := fmt.Sprintf("exec_%d", time.Now().UnixNano())
|
|
||||||
durationMs := endedAt.Sub(startedAt).Milliseconds()
|
|
||||||
|
|
||||||
execution := &ops.Execution{
|
|
||||||
ID: execID,
|
|
||||||
PipelineID: fn.ID,
|
|
||||||
Status: status,
|
|
||||||
StartedAt: startedAt,
|
|
||||||
EndedAt: &endedAt,
|
|
||||||
DurationMs: &durationMs,
|
|
||||||
Error: execErr,
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
|
|
||||||
insertErr := ops.InsertExecutionSafe(opsDB, execution)
|
|
||||||
if insertErr != nil {
|
|
||||||
return RunResult{
|
|
||||||
Stdout: stdout.String(),
|
|
||||||
Stderr: stderr.String(),
|
|
||||||
ExecID: execID,
|
|
||||||
PipelineID: fn.ID,
|
|
||||||
Status: status,
|
|
||||||
DurationMs: durationMs,
|
|
||||||
Err: fmt.Errorf("pipeline ran but failed to record: %w", insertErr),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RunResult{
|
|
||||||
Stdout: stdout.String(),
|
|
||||||
Stderr: stderr.String(),
|
|
||||||
ExecID: execID,
|
|
||||||
PipelineID: fn.ID,
|
|
||||||
Status: status,
|
|
||||||
DurationMs: durationMs,
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: assert_docker_container_running
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "assert_docker_container_running(container_name: string) -> void"
|
||||||
|
description: "Verifica que un contenedor Docker está corriendo. Sale con exit code 1 si no está activo, con mensaje a stderr."
|
||||||
|
tags: [assert, docker, container, running, validation, infra, bash]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: container_name
|
||||||
|
desc: "nombre del contenedor Docker a verificar"
|
||||||
|
output: "sin salida; exit code 0 si existe y está corriendo, 1 si no"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/assert_docker_container_running.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source functions/infra/assert_docker_container_running.sh
|
||||||
|
|
||||||
|
assert_docker_container_running metabase
|
||||||
|
echo "Contenedor activo, continuando..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `docker ps --format '{{.Names}}'` con grep anclado (`^name$`) para evitar matches parciales (ej: "metabase" no matchea "metabase-test").
|
||||||
|
|
||||||
|
Output limpio: void en éxito. El mensaje de error en stderr no incluye lista de contenedores activos — eso es responsabilidad del pipeline/caller.
|
||||||
|
|
||||||
|
Requiere que `docker` esté en PATH. Combinar con `assert_command_exists` antes de llamar.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# assert_docker_container_running
|
||||||
|
# --------------------------------
|
||||||
|
# Verifica que un contenedor Docker está corriendo.
|
||||||
|
# No produce output a stdout en caso de éxito.
|
||||||
|
# Sale con exit code 1 si el contenedor no está corriendo,
|
||||||
|
# con mensaje descriptivo a stderr.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source assert_docker_container_running.sh
|
||||||
|
# assert_docker_container_running metabase
|
||||||
|
|
||||||
|
assert_docker_container_running() {
|
||||||
|
local container_name="$1"
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||||
|
echo "assert_docker_container_running: el contenedor '$container_name' no está corriendo" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: build_cpp_linux
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "build_cpp_linux(target?: string) -> void"
|
||||||
|
description: "Compila las funciones y apps C++ del registry para Linux nativo usando cmake"
|
||||||
|
tags: [cpp, build, cmake, linux, imgui]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/build_cpp_linux.sh"
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||||
|
output: "Compila los binarios en cpp/build/linux/"
|
||||||
|
---
|
||||||
|
|
||||||
|
# build_cpp_linux
|
||||||
|
|
||||||
|
Configura y compila el proyecto C++ (ImGui/ImPlot) para Linux nativo.
|
||||||
|
|
||||||
|
Usa cmake con compilacion paralela (`-j$(nproc)`). Si no se ha configurado antes, ejecuta `cmake -B` automaticamente.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn run build_cpp_linux # Compilar todo
|
||||||
|
fn run build_cpp_linux chart_demo # Compilar solo chart_demo
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||||
|
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||||
|
BUILD_DIR="$CPP_ROOT/build/linux"
|
||||||
|
TARGET="${1:-}"
|
||||||
|
|
||||||
|
# Configure if needed
|
||||||
|
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||||
|
echo "[build_cpp_linux] Configuring cmake..."
|
||||||
|
cmake -B "$BUILD_DIR" -S "$CPP_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
echo "[build_cpp_linux] Building target: $TARGET"
|
||||||
|
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||||
|
else
|
||||||
|
echo "[build_cpp_linux] Building all targets..."
|
||||||
|
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[build_cpp_linux] Done. Binaries in $BUILD_DIR"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: build_cpp_windows
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "build_cpp_windows(target?: string) -> void"
|
||||||
|
description: "Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64"
|
||||||
|
tags: [cpp, build, cmake, windows, cross-compile, mingw, imgui]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/build_cpp_windows.sh"
|
||||||
|
params:
|
||||||
|
- name: target
|
||||||
|
desc: "Nombre del target cmake a compilar (opcional, sin argumento compila todo)"
|
||||||
|
output: "Produce binarios .exe de Windows en cpp/build/windows/"
|
||||||
|
---
|
||||||
|
|
||||||
|
# build_cpp_windows
|
||||||
|
|
||||||
|
Cross-compila el proyecto C++ para Windows desde Linux usando el toolchain mingw-w64.
|
||||||
|
|
||||||
|
Los .exe resultantes incluyen runtime linkado estaticamente (self-contained).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn run build_cpp_windows # Compilar todo
|
||||||
|
fn run build_cpp_windows chart_demo # Compilar solo chart_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Requiere `mingw-w64`: `sudo apt install mingw-w64`
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY_ROOT="${FN_REGISTRY_ROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
|
||||||
|
CPP_ROOT="$REGISTRY_ROOT/cpp"
|
||||||
|
BUILD_DIR="$CPP_ROOT/build/windows"
|
||||||
|
TOOLCHAIN="$CPP_ROOT/toolchains/mingw-w64.cmake"
|
||||||
|
TARGET="${1:-}"
|
||||||
|
|
||||||
|
# Check mingw is available
|
||||||
|
if ! command -v x86_64-w64-mingw32-g++ &>/dev/null; then
|
||||||
|
echo "[build_cpp_windows] Error: mingw-w64 not found. Install with: sudo apt install mingw-w64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure if needed
|
||||||
|
if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then
|
||||||
|
echo "[build_cpp_windows] Configuring cmake with mingw-w64 toolchain..."
|
||||||
|
cmake -B "$BUILD_DIR" -S "$CPP_ROOT" -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
echo "[build_cpp_windows] Cross-compiling target: $TARGET"
|
||||||
|
cmake --build "$BUILD_DIR" --target "$TARGET" -- -j"$(nproc)"
|
||||||
|
else
|
||||||
|
echo "[build_cpp_windows] Cross-compiling all targets..."
|
||||||
|
cmake --build "$BUILD_DIR" -- -j"$(nproc)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[build_cpp_windows] Done. Windows binaries in $BUILD_DIR"
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
file "$BUILD_DIR"/**/"$TARGET".exe 2>/dev/null || file "$BUILD_DIR/$TARGET".exe 2>/dev/null || true
|
||||||
|
fi
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: docker_cp_file
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "docker_cp_file(local_path: string, container_name: string, dest_path: string) -> string"
|
||||||
|
description: "Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide. Imprime JSON con local_size y remote_size a stdout. Sale con exit code 1 si docker cp falla o los tamaños difieren."
|
||||||
|
tags: [docker, cp, copy, file, container, transfer, infra, bash]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: local_path
|
||||||
|
desc: "ruta del archivo local a copiar"
|
||||||
|
- name: container_name
|
||||||
|
desc: "nombre del contenedor Docker destino"
|
||||||
|
- name: dest_path
|
||||||
|
desc: "ruta destino dentro del contenedor"
|
||||||
|
output: "JSON con local_size y remote_size en bytes"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/docker_cp_file.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source functions/infra/docker_cp_file.sh
|
||||||
|
|
||||||
|
result=$(docker_cp_file /home/lucas/fn_registry/registry.db metabase /registry.db)
|
||||||
|
echo "$result"
|
||||||
|
# {"local_size":524288,"remote_size":524288}
|
||||||
|
|
||||||
|
local_size=$(echo "$result" | grep -o '"local_size":[0-9]*' | cut -d: -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
La verificación de tamaño usa `docker exec stat -c%s` sobre el contenedor destino. Si `stat` no está disponible en el contenedor, `remote_size` será -1 y la función fallará.
|
||||||
|
|
||||||
|
Output a stdout: JSON minificado con campos `local_size` y `remote_size` (enteros, bytes).
|
||||||
|
|
||||||
|
Usa `printf` en lugar de `echo` para garantizar que no haya newline extra en el JSON.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# docker_cp_file
|
||||||
|
# --------------
|
||||||
|
# Copia un archivo local a un contenedor Docker y verifica que el tamaño coincide.
|
||||||
|
# Imprime JSON con local_size y remote_size a stdout si la copia es exitosa.
|
||||||
|
# Sale con exit code 1 si docker cp falla o si los tamaños no coinciden.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source docker_cp_file.sh
|
||||||
|
# result=$(docker_cp_file /ruta/local.db metabase /dest/path.db)
|
||||||
|
|
||||||
|
docker_cp_file() {
|
||||||
|
local local_path="$1"
|
||||||
|
local container_name="$2"
|
||||||
|
local dest_path="$3"
|
||||||
|
|
||||||
|
if ! docker cp "$local_path" "${container_name}:${dest_path}" 2>/dev/null; then
|
||||||
|
echo "docker_cp_file: fallo al copiar '$local_path' a '${container_name}:${dest_path}'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local local_size
|
||||||
|
local_size=$(stat -c%s "$local_path")
|
||||||
|
|
||||||
|
local remote_size
|
||||||
|
remote_size=$(docker exec "$container_name" stat -c%s "$dest_path" 2>/dev/null || echo "-1")
|
||||||
|
|
||||||
|
if [ "$local_size" != "$remote_size" ]; then
|
||||||
|
echo "docker_cp_file: tamaños no coinciden (local=${local_size}, remoto=${remote_size})" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"local_size":%s,"remote_size":%s}' "$local_size" "$remote_size"
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: frontend_doctor
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "frontend_doctor(project_dir: string) -> diagnostics_stdout"
|
||||||
|
description: "Diagnostica la salud de un proyecto frontend Mantine. Verifica Node, React, Mantine, PostCSS, TypeScript, vite.config y detecta residuos de shadcn/@base-ui. Imprime tabla de checks con exit code 0/1."
|
||||||
|
tags: [frontend, mantine, doctor, diagnostics, health, validation]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio del proyecto frontend con package.json"
|
||||||
|
output: "tabla de checks con ✓/✗ por cada validación y resumen final"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/frontend_doctor.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Diagnosticar un proyecto
|
||||||
|
bash frontend_doctor.sh ./apps/rapid_dashboards/frontend
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# === Frontend Doctor: ./apps/rapid_dashboards/frontend ===
|
||||||
|
#
|
||||||
|
# ✓ Node >= 18 22.12.0
|
||||||
|
# ✓ Package manager detected pnpm
|
||||||
|
# ✓ node_modules present
|
||||||
|
# ✓ @mantine/core 7.17.0
|
||||||
|
# ✓ @mantine/hooks
|
||||||
|
# ✓ @mantine/charts
|
||||||
|
# ✓ React >= 18 19.2.4
|
||||||
|
# ✓ postcss.config present
|
||||||
|
# ✓ TypeScript >= 5 6.0.2
|
||||||
|
# ✓ vite.config present
|
||||||
|
# ✓ No shadcn residual
|
||||||
|
# ✓ No @base-ui residual
|
||||||
|
#
|
||||||
|
# Resultado: todo OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Checks informativos, no modifica nada. Util para validar que un proyecto esta correctamente configurado despues de instalar Mantine o migrar desde shadcn. Exit code 0 si todo OK, 1 si hay problemas.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# frontend_doctor
|
||||||
|
# ----------------
|
||||||
|
# Diagnostica la salud de un proyecto frontend Mantine.
|
||||||
|
# Verifica dependencias, configuracion y versiones.
|
||||||
|
# Imprime tabla de checks y retorna exit code 0 (ok) o 1 (fallos).
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source frontend_doctor.sh
|
||||||
|
# frontend_doctor /path/to/frontend
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# bash frontend_doctor.sh /path/to/frontend
|
||||||
|
|
||||||
|
frontend_doctor() {
|
||||||
|
local project_dir="$1"
|
||||||
|
local failures=0
|
||||||
|
|
||||||
|
if [ -z "$project_dir" ]; then
|
||||||
|
echo "frontend_doctor: se requiere project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$project_dir/package.json" ]; then
|
||||||
|
echo "frontend_doctor: no existe package.json en $project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Frontend Doctor: $project_dir ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Helper: check y reportar
|
||||||
|
_check() {
|
||||||
|
local label="$1"
|
||||||
|
local ok="$2"
|
||||||
|
local detail="$3"
|
||||||
|
if [ "$ok" = "1" ]; then
|
||||||
|
printf " ✓ %-35s %s\n" "$label" "$detail"
|
||||||
|
else
|
||||||
|
printf " ✗ %-35s %s\n" "$label" "$detail"
|
||||||
|
((failures++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Node >= 18
|
||||||
|
local node_ver=""
|
||||||
|
local node_ok=0
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
node_ver=$(node -v 2>/dev/null | sed 's/v//')
|
||||||
|
local node_major=$(echo "$node_ver" | cut -d. -f1)
|
||||||
|
[ "$node_major" -ge 18 ] 2>/dev/null && node_ok=1
|
||||||
|
fi
|
||||||
|
_check "Node >= 18" "$node_ok" "${node_ver:-not found}"
|
||||||
|
|
||||||
|
# 2. Package manager
|
||||||
|
local pm_ok=0
|
||||||
|
local pm_name="none"
|
||||||
|
if [ -f "$project_dir/pnpm-lock.yaml" ]; then
|
||||||
|
pm_name="pnpm"; pm_ok=1
|
||||||
|
elif [ -f "$project_dir/yarn.lock" ]; then
|
||||||
|
pm_name="yarn"; pm_ok=1
|
||||||
|
elif [ -f "$project_dir/package-lock.json" ]; then
|
||||||
|
pm_name="npm"; pm_ok=1
|
||||||
|
fi
|
||||||
|
_check "Package manager detected" "$pm_ok" "$pm_name"
|
||||||
|
|
||||||
|
# 3. node_modules existe
|
||||||
|
local nm_ok=0
|
||||||
|
[ -d "$project_dir/node_modules" ] && nm_ok=1
|
||||||
|
_check "node_modules present" "$nm_ok" ""
|
||||||
|
|
||||||
|
# 4. @mantine/core instalado
|
||||||
|
local mantine_ok=0
|
||||||
|
local mantine_ver=""
|
||||||
|
if [ -f "$project_dir/node_modules/@mantine/core/package.json" ]; then
|
||||||
|
mantine_ver=$(node -e "console.log(require('$project_dir/node_modules/@mantine/core/package.json').version)" 2>/dev/null)
|
||||||
|
mantine_ok=1
|
||||||
|
fi
|
||||||
|
_check "@mantine/core" "$mantine_ok" "${mantine_ver:-not installed}"
|
||||||
|
|
||||||
|
# 5. @mantine/hooks
|
||||||
|
local hooks_ok=0
|
||||||
|
[ -d "$project_dir/node_modules/@mantine/hooks" ] && hooks_ok=1
|
||||||
|
_check "@mantine/hooks" "$hooks_ok" ""
|
||||||
|
|
||||||
|
# 6. @mantine/charts
|
||||||
|
local charts_ok=0
|
||||||
|
[ -d "$project_dir/node_modules/@mantine/charts" ] && charts_ok=1
|
||||||
|
_check "@mantine/charts" "$charts_ok" ""
|
||||||
|
|
||||||
|
# 7. React >= 18
|
||||||
|
local react_ok=0
|
||||||
|
local react_ver=""
|
||||||
|
if [ -f "$project_dir/node_modules/react/package.json" ]; then
|
||||||
|
react_ver=$(node -e "console.log(require('$project_dir/node_modules/react/package.json').version)" 2>/dev/null)
|
||||||
|
local react_major=$(echo "$react_ver" | cut -d. -f1)
|
||||||
|
[ "$react_major" -ge 18 ] 2>/dev/null && react_ok=1
|
||||||
|
fi
|
||||||
|
_check "React >= 18" "$react_ok" "${react_ver:-not found}"
|
||||||
|
|
||||||
|
# 8. postcss.config presente
|
||||||
|
local postcss_ok=0
|
||||||
|
if [ -f "$project_dir/postcss.config.cjs" ] || [ -f "$project_dir/postcss.config.js" ] || [ -f "$project_dir/postcss.config.mjs" ]; then
|
||||||
|
postcss_ok=1
|
||||||
|
fi
|
||||||
|
_check "postcss.config present" "$postcss_ok" ""
|
||||||
|
|
||||||
|
# 9. TypeScript >= 5
|
||||||
|
local ts_ok=0
|
||||||
|
local ts_ver=""
|
||||||
|
if [ -f "$project_dir/node_modules/typescript/package.json" ]; then
|
||||||
|
ts_ver=$(node -e "console.log(require('$project_dir/node_modules/typescript/package.json').version)" 2>/dev/null)
|
||||||
|
local ts_major=$(echo "$ts_ver" | cut -d. -f1)
|
||||||
|
[ "$ts_major" -ge 5 ] 2>/dev/null && ts_ok=1
|
||||||
|
fi
|
||||||
|
_check "TypeScript >= 5" "$ts_ok" "${ts_ver:-not found}"
|
||||||
|
|
||||||
|
# 10. vite.config presente
|
||||||
|
local vite_ok=0
|
||||||
|
if [ -f "$project_dir/vite.config.ts" ] || [ -f "$project_dir/vite.config.js" ]; then
|
||||||
|
vite_ok=1
|
||||||
|
fi
|
||||||
|
_check "vite.config present" "$vite_ok" ""
|
||||||
|
|
||||||
|
# 11. Shadcn residual (warning)
|
||||||
|
local shadcn_clean=1
|
||||||
|
if [ -f "$project_dir/components.json" ]; then
|
||||||
|
shadcn_clean=0
|
||||||
|
fi
|
||||||
|
_check "No shadcn residual" "$shadcn_clean" "$([ "$shadcn_clean" = "0" ] && echo 'components.json found')"
|
||||||
|
|
||||||
|
# 12. @base-ui residual (warning)
|
||||||
|
local baseui_clean=1
|
||||||
|
if [ -d "$project_dir/node_modules/@base-ui" ]; then
|
||||||
|
baseui_clean=0
|
||||||
|
fi
|
||||||
|
_check "No @base-ui residual" "$baseui_clean" "$([ "$baseui_clean" = "0" ] && echo '@base-ui still installed')"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$failures" -eq 0 ]; then
|
||||||
|
echo " Resultado: todo OK"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo " Resultado: $failures problema(s) encontrado(s)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
frontend_doctor "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: gitea_add_collaborator
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_add_collaborator(owner: string, repo: string, username: string, permission: string) -> void"
|
||||||
|
description: "Añade un colaborador a un repositorio Gitea con el nivel de permisos indicado. Silencioso si el colaborador ya existe (422)."
|
||||||
|
tags: [gitea, git, collaborator, permission, repo, api, 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: username
|
||||||
|
desc: "nombre de usuario del colaborador a añadir"
|
||||||
|
- name: permission
|
||||||
|
desc: "nivel de permisos: 'read', 'write' o 'admin' (default: admin)"
|
||||||
|
output: "vacío — efectos observables a través de la API de Gitea"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_add_collaborator.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_add_collaborator.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Añadir colaborador con permiso admin (default)
|
||||||
|
gitea_add_collaborator "myorg" "my-app" "egutierrez"
|
||||||
|
|
||||||
|
# Añadir colaborador con permiso de solo lectura
|
||||||
|
gitea_add_collaborator "myorg" "my-app" "reviewer" "read"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||||
|
- Un 422 de la API indica que el usuario ya es colaborador — se trata como éxito silencioso.
|
||||||
|
- La función no produce salida a stdout; los mensajes informativos van a stderr.
|
||||||
|
- Nivel `admin` da acceso completo al repo incluyendo settings.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_add_collaborator — Añade un colaborador a un repositorio Gitea
|
||||||
|
|
||||||
|
gitea_add_collaborator() {
|
||||||
|
local owner="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local username="$3"
|
||||||
|
local permission="${4:-admin}"
|
||||||
|
|
||||||
|
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||||
|
echo "gitea_add_collaborator: GITEA_URL no está seteada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo "gitea_add_collaborator: GITEA_TOKEN no está seteado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$owner" || -z "$repo" || -z "$username" ]]; then
|
||||||
|
echo "gitea_add_collaborator: se requieren owner, repo y username" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(printf '{"permission":"%s"}' "$permission")
|
||||||
|
|
||||||
|
echo "gitea_add_collaborator: añadiendo '$username' a '$owner/$repo' con permiso '$permission'..." >&2
|
||||||
|
|
||||||
|
local response http_code
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X PUT \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${owner}/${repo}/collaborators/${username}")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$http_code" == "204" || "$http_code" == "200" ]]; then
|
||||||
|
echo "gitea_add_collaborator: '$username' añadido a '$owner/$repo'" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$http_code" == "422" ]]; then
|
||||||
|
echo "gitea_add_collaborator: '$username' ya es colaborador de '$owner/$repo' (silencioso)" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "gitea_add_collaborator: error (HTTP ${http_code}): ${body}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: gitea_create_repo
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_create_repo(owner: string, name: string, private: string, description: string) -> string"
|
||||||
|
description: "Crea un repositorio en Gitea para un owner. Intenta crearlo en org primero; si el owner no es una org (404/422), lo crea en el usuario autenticado. No falla fatalmente si el repo ya existe (409)."
|
||||||
|
tags: [gitea, git, repo, create, infra, api]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: owner
|
||||||
|
desc: "usuario u organización propietaria del repo"
|
||||||
|
- name: name
|
||||||
|
desc: "nombre del repositorio a crear"
|
||||||
|
- name: private
|
||||||
|
desc: "si el repo es privado, 'true' o 'false' (default: false)"
|
||||||
|
- name: description
|
||||||
|
desc: "descripción del repositorio (opcional)"
|
||||||
|
output: "JSON del repositorio creado según la API de Gitea"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_create_repo.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_create_repo.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Crear repo público en org o usuario
|
||||||
|
repo_json=$(gitea_create_repo "myorg" "my-app")
|
||||||
|
|
||||||
|
# Crear repo privado con descripción
|
||||||
|
repo_json=$(gitea_create_repo "myorg" "my-app" "true" "Mi aplicación principal")
|
||||||
|
|
||||||
|
# Extraer la URL del clon
|
||||||
|
clone_url=$(echo "$repo_json" | jq -r '.clone_url')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere variables de entorno `GITEA_URL` y `GITEA_TOKEN` seteadas antes de invocar.
|
||||||
|
- El fallback org → usuario ocurre con HTTP 404 o 422 en el endpoint de orgs.
|
||||||
|
- Un 409 se reporta a stderr pero la función retorna 0 — el repo ya existe es una condición aceptable para idempotencia.
|
||||||
|
- Los mensajes informativos van a stderr; el JSON de respuesta va a stdout.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_create_repo — Crea un repositorio en Gitea para un owner (org o usuario)
|
||||||
|
|
||||||
|
gitea_create_repo() {
|
||||||
|
local owner="$1"
|
||||||
|
local name="$2"
|
||||||
|
local private="${3:-false}"
|
||||||
|
local description="${4:-}"
|
||||||
|
|
||||||
|
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||||
|
echo "gitea_create_repo: GITEA_URL no está seteada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo "gitea_create_repo: GITEA_TOKEN no está seteado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$owner" || -z "$name" ]]; then
|
||||||
|
echo "gitea_create_repo: se requieren owner y name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(printf '{"name":"%s","private":%s,"description":"%s","auto_init":false}' \
|
||||||
|
"$name" "$private" "$description")
|
||||||
|
|
||||||
|
echo "gitea_create_repo: intentando crear '$owner/$name' en org..." >&2
|
||||||
|
|
||||||
|
local response http_code
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${GITEA_URL}/api/v1/orgs/${owner}/repos")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$http_code" == "201" ]]; then
|
||||||
|
echo "gitea_create_repo: repo '$owner/$name' creado en org" >&2
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$http_code" == "409" ]]; then
|
||||||
|
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$http_code" == "404" || "$http_code" == "422" ]]; then
|
||||||
|
echo "gitea_create_repo: org no encontrada (${http_code}), intentando en usuario..." >&2
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-d "$payload" \
|
||||||
|
"${GITEA_URL}/api/v1/user/repos")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$http_code" == "201" ]]; then
|
||||||
|
echo "gitea_create_repo: repo '$owner/$name' creado en usuario" >&2
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$http_code" == "409" ]]; then
|
||||||
|
echo "gitea_create_repo: repo '$owner/$name' ya existe (409)" >&2
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "gitea_create_repo: error al crear en usuario (HTTP ${http_code}): ${body}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "gitea_create_repo: error inesperado (HTTP ${http_code}): ${body}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: gitea_list_repos
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_list_repos(owner: string) -> string"
|
||||||
|
description: "Lista repositorios de un owner en Gitea. Intenta listar como org primero; si falla, lista como usuario. Imprime una línea por repo en formato name<TAB>html_url<TAB>description."
|
||||||
|
tags: [gitea, git, repo, list, org, user, api, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: owner
|
||||||
|
desc: "nombre del usuario u organización cuyos repos se listan"
|
||||||
|
output: "una línea por repositorio con columnas separadas por tabulador: name, html_url, description"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_list_repos.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_list_repos.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Listar todos los repos de una org
|
||||||
|
gitea_list_repos "myorg"
|
||||||
|
# my-app https://git.example.com/myorg/my-app Mi aplicación principal
|
||||||
|
# infra https://git.example.com/myorg/infra
|
||||||
|
|
||||||
|
# Iterar sobre los repos
|
||||||
|
while IFS=$'\t' read -r name url desc; do
|
||||||
|
echo "Repo: $name — $url"
|
||||||
|
done < <(gitea_list_repos "myorg")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||||
|
- Usa `jq` si está disponible; fallback con grep/sed en caso contrario.
|
||||||
|
- El límite es 50 repos por página. Para owners con más de 50 repos habría que implementar paginación.
|
||||||
|
- Los mensajes informativos van a stderr; los datos tabulados van a stdout.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_list_repos — Lista repositorios de un owner (org o usuario) en Gitea
|
||||||
|
|
||||||
|
gitea_list_repos() {
|
||||||
|
local owner="$1"
|
||||||
|
|
||||||
|
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||||
|
echo "gitea_list_repos: GITEA_URL no está seteada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo "gitea_list_repos: GITEA_TOKEN no está seteado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$owner" ]]; then
|
||||||
|
echo "gitea_list_repos: se requiere owner" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "gitea_list_repos: listando repos de '$owner' (intentando org)..." >&2
|
||||||
|
|
||||||
|
local response http_code body
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/orgs/${owner}/repos?limit=50")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$http_code" != "200" ]]; then
|
||||||
|
echo "gitea_list_repos: org no encontrada (${http_code}), intentando usuario..." >&2
|
||||||
|
response=$(curl -s -w "\n%{http_code}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/users/${owner}/repos?limit=50")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$http_code" != "200" ]]; then
|
||||||
|
echo "gitea_list_repos: error listando repos de usuario (HTTP ${http_code}): ${body}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Formatear salida como name\thtml_url\tdescription
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
echo "$body" | jq -r '.[] | [.name, .html_url, (.description // "")] | @tsv'
|
||||||
|
else
|
||||||
|
# Fallback sin jq: extraer campos básicos con grep/sed
|
||||||
|
echo "$body" | grep -o '"name":"[^"]*"\|"html_url":"[^"]*"\|"description":"[^"]*"' \
|
||||||
|
| paste - - - | sed 's/"name":"//;s/"html_url":"//;s/"description":"//;s/"//g'
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: gitea_push_directory
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "gitea_push_directory(directory: string, owner: string, repo: string, branch: string) -> void"
|
||||||
|
description: "Inicializa git en un directorio local y lo sube a un repositorio Gitea existente. Si el directorio ya tiene .git, actualiza el remote y pushea cambios pendientes. Protege registry.db añadiéndolo al .gitignore antes del commit."
|
||||||
|
tags: [gitea, git, push, directory, sync, infra, repo]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: directory
|
||||||
|
desc: "ruta absoluta o relativa al directorio local a subir"
|
||||||
|
- name: owner
|
||||||
|
desc: "usuario u organización propietaria del repositorio Gitea destino"
|
||||||
|
- name: repo
|
||||||
|
desc: "nombre del repositorio Gitea destino (debe existir previamente)"
|
||||||
|
- name: branch
|
||||||
|
desc: "rama en la que hacer push (default: main)"
|
||||||
|
output: "vacío — efectos observables en el repositorio Gitea destino"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/gitea_push_directory.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source bash/functions/infra/gitea_push_directory.sh
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_TOKEN="$(pass agentes/dataforge-token)"
|
||||||
|
|
||||||
|
# Subir directorio a repo existente
|
||||||
|
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app"
|
||||||
|
|
||||||
|
# Subir a rama específica
|
||||||
|
gitea_push_directory "/home/lucas/myproject" "myorg" "my-app" "develop"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Requiere `GITEA_URL` y `GITEA_TOKEN` seteadas.
|
||||||
|
- El token se embebe en la URL del remote para autenticación (nunca se imprime a stdout/stderr, se enmascara con ***).
|
||||||
|
- Si `registry.db` existe en el directorio, se añade automáticamente al `.gitignore` local.
|
||||||
|
- Si el `.git` ya existe con un remote diferente, se redirige al repo indicado sin perder el historial local.
|
||||||
|
- Usa `--force-with-lease` para el primer push y fallback a push normal (para repos vacíos recién creados).
|
||||||
|
- El commit se firma con `agent@fn-registry` si no hay configuración git en el entorno.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# gitea_push_directory — Inicializa git en un directorio y lo sube a un repo Gitea existente
|
||||||
|
|
||||||
|
gitea_push_directory() {
|
||||||
|
local directory="$1"
|
||||||
|
local owner="$2"
|
||||||
|
local repo="$3"
|
||||||
|
local branch="${4:-main}"
|
||||||
|
|
||||||
|
if [[ -z "${GITEA_URL:-}" ]]; then
|
||||||
|
echo "gitea_push_directory: GITEA_URL no está seteada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo "gitea_push_directory: GITEA_TOKEN no está seteado" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$directory" || -z "$owner" || -z "$repo" ]]; then
|
||||||
|
echo "gitea_push_directory: se requieren directory, owner y repo" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$directory" ]]; then
|
||||||
|
echo "gitea_push_directory: directorio '$directory' no existe" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Construir URL con credenciales embebidas para autenticación
|
||||||
|
local gitea_host
|
||||||
|
gitea_host=$(echo "$GITEA_URL" | sed 's|https\?://||')
|
||||||
|
local remote_url="https://${GITEA_TOKEN}@${gitea_host}/${owner}/${repo}.git"
|
||||||
|
local display_url="https://***@${gitea_host}/${owner}/${repo}.git"
|
||||||
|
|
||||||
|
echo "gitea_push_directory: procesando '$directory' → '$owner/$repo' (rama: $branch)..." >&2
|
||||||
|
|
||||||
|
# Añadir registry.db al .gitignore local si existe en el directorio
|
||||||
|
if [[ -f "$directory/registry.db" ]]; then
|
||||||
|
echo "gitea_push_directory: añadiendo registry.db al .gitignore..." >&2
|
||||||
|
if [[ ! -f "$directory/.gitignore" ]] || ! grep -qxF "registry.db" "$directory/.gitignore"; then
|
||||||
|
echo "registry.db" >> "$directory/.gitignore"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gestionar estado del repositorio git
|
||||||
|
if [[ -d "$directory/.git" ]]; then
|
||||||
|
local existing_remote
|
||||||
|
existing_remote=$(git -C "$directory" remote get-url origin 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$existing_remote" ]]; then
|
||||||
|
echo "gitea_push_directory: añadiendo remote origin..." >&2
|
||||||
|
git -C "$directory" remote add origin "$remote_url"
|
||||||
|
else
|
||||||
|
# Comparar remote sin token para detectar si apunta al mismo repo
|
||||||
|
local clean_existing
|
||||||
|
clean_existing=$(echo "$existing_remote" | sed 's|https://[^@]*@||;s|https://||')
|
||||||
|
local clean_target="${gitea_host}/${owner}/${repo}.git"
|
||||||
|
|
||||||
|
if [[ "$clean_existing" != "$clean_target" ]]; then
|
||||||
|
echo "gitea_push_directory: remote apunta a otro destino ('$clean_existing'), actualizando..." >&2
|
||||||
|
git -C "$directory" remote set-url origin "$remote_url"
|
||||||
|
else
|
||||||
|
echo "gitea_push_directory: remote ya apunta al destino correcto, actualizando token..." >&2
|
||||||
|
git -C "$directory" remote set-url origin "$remote_url"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "gitea_push_directory: inicializando nuevo repositorio git..." >&2
|
||||||
|
git -C "$directory" init
|
||||||
|
git -C "$directory" remote add origin "$remote_url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configurar rama por defecto
|
||||||
|
git -C "$directory" checkout -B "$branch" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Añadir y commitear cambios si los hay
|
||||||
|
git -C "$directory" add -A
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(git -C "$directory" status --porcelain)
|
||||||
|
|
||||||
|
if [[ -n "$status" ]]; then
|
||||||
|
echo "gitea_push_directory: commiteando cambios..." >&2
|
||||||
|
git -C "$directory" -c user.email="agent@fn-registry" -c user.name="fn-registry agent" \
|
||||||
|
commit -m "chore: sync from fn-registry agent"
|
||||||
|
else
|
||||||
|
echo "gitea_push_directory: sin cambios pendientes, solo haciendo push..." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "gitea_push_directory: haciendo push a $display_url..." >&2
|
||||||
|
git -C "$directory" push --set-upstream origin "$branch" --force-with-lease 2>&1 \
|
||||||
|
| sed "s|${GITEA_TOKEN}|***|g" >&2 \
|
||||||
|
|| git -C "$directory" push --set-upstream origin "$branch" 2>&1 \
|
||||||
|
| sed "s|${GITEA_TOKEN}|***|g" >&2
|
||||||
|
|
||||||
|
echo "gitea_push_directory: push completado a '$owner/$repo' rama '$branch'" >&2
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: init_uv_venv
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "init_uv_venv([project_dir: string]) -> string"
|
||||||
|
description: "Crea un virtualenv Python con uv en el directorio dado si no existe. Fallback a python3 -m venv. Retorna la ruta del venv."
|
||||||
|
tags: [python, venv, uv, setup, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio del proyecto donde crear el venv (default: directorio actual)"
|
||||||
|
output: "ruta absoluta del venv creado o existente"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/init_uv_venv.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source init_uv_venv.sh
|
||||||
|
venv=$(init_uv_venv /home/lucas/analysis/finanzas)
|
||||||
|
echo "Venv creado en: $venv"
|
||||||
|
|
||||||
|
# Idempotente — si ya existe, retorna la ruta sin recrear
|
||||||
|
venv=$(init_uv_venv .)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Idempotente: si el venv ya existe con un python valido, retorna la ruta sin hacer nada. Prefiere uv por velocidad, usa python3 como fallback.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# init_uv_venv
|
||||||
|
# -------------
|
||||||
|
# Crea un venv con uv en el directorio especificado si no existe.
|
||||||
|
# Fallback a python -m venv si uv no esta disponible.
|
||||||
|
# Imprime la ruta del venv a stdout.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source init_uv_venv.sh
|
||||||
|
# venv_path=$(init_uv_venv /path/to/project)
|
||||||
|
|
||||||
|
init_uv_venv() {
|
||||||
|
local project_dir="${1:-.}"
|
||||||
|
local venv_path="${project_dir}/.venv"
|
||||||
|
|
||||||
|
if [ -d "$venv_path" ] && [ -f "$venv_path/bin/python" ]; then
|
||||||
|
echo "$venv_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v uv &>/dev/null; then
|
||||||
|
(cd "$project_dir" && uv venv) >/dev/null 2>&1
|
||||||
|
elif command -v python3 &>/dev/null; then
|
||||||
|
python3 -m venv "$venv_path"
|
||||||
|
else
|
||||||
|
echo "init_uv_venv: ni uv ni python3 disponibles" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$venv_path/bin/python" ]; then
|
||||||
|
echo "init_uv_venv: fallo al crear venv en $venv_path" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$venv_path"
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: install_android_sdk
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_android_sdk() -> void"
|
||||||
|
description: "Descarga e instala Android SDK command-line tools y JDK 17 localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk). Idempotente: detecta instalacion existente y sale sin hacer nada. Genera env.sh con JAVA_HOME, ANDROID_HOME y PATH listos para hacer source."
|
||||||
|
tags: [android, sdk, jdk, java, install, infra, mobile]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "sin salida estructurada; imprime progreso y resumen final con rutas de instalacion"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_android_sdk.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalacion en directorio por defecto ($HOME/android-sdk)
|
||||||
|
source install_android_sdk.sh
|
||||||
|
|
||||||
|
# Instalacion en directorio personalizado
|
||||||
|
ANDROID_SDK_DIR=/opt/android source install_android_sdk.sh
|
||||||
|
|
||||||
|
# Si ya esta instalado:
|
||||||
|
# Android SDK ya instalado en: /home/user/android-sdk
|
||||||
|
|
||||||
|
# Instalacion completa imprime:
|
||||||
|
# Descargando JDK 17...
|
||||||
|
# JDK 17 instalado: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||||
|
# Descargando Android cmdline-tools...
|
||||||
|
# cmdline-tools instalados
|
||||||
|
# Aceptando licencias de Android SDK...
|
||||||
|
# Instalando platform-tools, platforms;android-34, build-tools;34.0.0...
|
||||||
|
#
|
||||||
|
# Android SDK instalado en: /home/user/android-sdk
|
||||||
|
# JDK 17: /home/user/android-sdk/jdk-17/jdk-17.0.x+y
|
||||||
|
# Para activar: source /home/user/android-sdk/env.sh
|
||||||
|
|
||||||
|
# Activar entorno en sesion actual
|
||||||
|
source ~/android-sdk/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere `curl` y `unzip` (disponibles en la mayoria de distros Linux). No requiere root ni sudo.
|
||||||
|
|
||||||
|
El JDK se descarga desde Adoptium (Eclipse Temurin) via su API oficial. La URL de cmdline-tools apunta a la version 11076708 (2024). Si Google actualiza la version, cambiar la URL con el nuevo numero de build.
|
||||||
|
|
||||||
|
La reorganizacion del zip es necesaria porque Google distribuye cmdline-tools con estructura `cmdline-tools/bin/...` pero sdkmanager espera estar en `cmdline-tools/latest/bin/sdkmanager` para que Android Studio y otras herramientas lo detecten correctamente.
|
||||||
|
|
||||||
|
El archivo `env.sh` generado en `$ANDROID_SDK_DIR/env.sh` contiene las variables de entorno necesarias (`JAVA_HOME`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `PATH`) y puede hacerse source desde `.bashrc`, `.zshrc` o desde scripts de CI.
|
||||||
|
|
||||||
|
Paquetes instalados: `platform-tools` (adb, fastboot), `platforms;android-34` (API 34), `build-tools;34.0.0`.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install_android_sdk — Descarga e instala Android SDK command-line tools y JDK 17
|
||||||
|
# localmente (sin root/sudo) en $ANDROID_SDK_DIR (default: $HOME/android-sdk).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
install_android_sdk() {
|
||||||
|
local sdk_dir="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
||||||
|
# Limpia temporales al salir
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
# 1. Verifica si ya está instalado
|
||||||
|
if [[ -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||||
|
if JAVA_HOME="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1)" \
|
||||||
|
"$sdk_dir/cmdline-tools/latest/bin/sdkmanager" --version &>/dev/null; then
|
||||||
|
echo "Android SDK ya instalado en: $sdk_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$sdk_dir"
|
||||||
|
|
||||||
|
# 2. Descarga JDK 17 si no existe
|
||||||
|
local jdk_dir
|
||||||
|
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||||
|
|
||||||
|
if [[ -z "$jdk_dir" ]]; then
|
||||||
|
echo "Descargando JDK 17..."
|
||||||
|
local jdk_tar="$tmp_dir/jdk17.tar.gz"
|
||||||
|
local jdk_url="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
||||||
|
|
||||||
|
if ! curl -fL --progress-bar -o "$jdk_tar" "$jdk_url"; then
|
||||||
|
echo "ERROR: fallo al descargar JDK 17 desde $jdk_url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$sdk_dir/jdk-17"
|
||||||
|
echo "Extrayendo JDK 17..."
|
||||||
|
if ! tar -xzf "$jdk_tar" -C "$sdk_dir/jdk-17"; then
|
||||||
|
echo "ERROR: fallo al extraer JDK 17" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
jdk_dir="$(ls -d "$sdk_dir"/jdk-17/jdk-17* 2>/dev/null | head -1 || true)"
|
||||||
|
if [[ -z "$jdk_dir" ]]; then
|
||||||
|
echo "ERROR: no se encontro directorio jdk-17* tras la extraccion" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! JAVA_HOME="$jdk_dir" "$jdk_dir/bin/java" -version &>/dev/null; then
|
||||||
|
echo "ERROR: java -version fallo tras instalar JDK" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "JDK 17 instalado: $jdk_dir"
|
||||||
|
else
|
||||||
|
echo "JDK 17 ya presente: $jdk_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export JAVA_HOME="$jdk_dir"
|
||||||
|
|
||||||
|
# 3. Descarga Android cmdline-tools si no existen
|
||||||
|
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||||
|
echo "Descargando Android cmdline-tools..."
|
||||||
|
local tools_zip="$tmp_dir/cmdline-tools.zip"
|
||||||
|
local tools_url="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||||
|
|
||||||
|
if ! curl -fL --progress-bar -o "$tools_zip" "$tools_url"; then
|
||||||
|
echo "ERROR: fallo al descargar Android cmdline-tools desde $tools_url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tools_tmp="$tmp_dir/cmdline-tools-extracted"
|
||||||
|
mkdir -p "$tools_tmp"
|
||||||
|
echo "Extrayendo cmdline-tools..."
|
||||||
|
if ! unzip -q "$tools_zip" -d "$tools_tmp"; then
|
||||||
|
echo "ERROR: fallo al extraer cmdline-tools" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# La estructura del zip es cmdline-tools/bin/..., reorganizar a cmdline-tools/latest/
|
||||||
|
mkdir -p "$sdk_dir/cmdline-tools"
|
||||||
|
if [[ -d "$tools_tmp/cmdline-tools" ]]; then
|
||||||
|
mv "$tools_tmp/cmdline-tools" "$sdk_dir/cmdline-tools/latest"
|
||||||
|
else
|
||||||
|
echo "ERROR: estructura inesperada en el zip de cmdline-tools" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$sdk_dir/cmdline-tools/latest/bin/sdkmanager" ]]; then
|
||||||
|
echo "ERROR: sdkmanager no encontrado tras extraer cmdline-tools" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "cmdline-tools instalados"
|
||||||
|
else
|
||||||
|
echo "cmdline-tools ya presentes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local sdkmanager="$sdk_dir/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
export ANDROID_HOME="$sdk_dir"
|
||||||
|
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||||
|
export PATH="$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:$PATH"
|
||||||
|
|
||||||
|
# 4. Acepta licencias e instala paquetes necesarios
|
||||||
|
echo "Aceptando licencias de Android SDK..."
|
||||||
|
if ! yes | "$sdkmanager" --licenses; then
|
||||||
|
echo "ERROR: fallo al aceptar licencias de Android SDK" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando platform-tools, platforms;android-34, build-tools;34.0.0..."
|
||||||
|
if ! "$sdkmanager" "platform-tools" "platforms;android-34" "build-tools;34.0.0"; then
|
||||||
|
echo "ERROR: fallo al instalar paquetes de Android SDK" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Genera archivo de entorno
|
||||||
|
local env_file="$sdk_dir/env.sh"
|
||||||
|
cat > "$env_file" <<EOF
|
||||||
|
export JAVA_HOME="$JAVA_HOME"
|
||||||
|
export ANDROID_HOME="$sdk_dir"
|
||||||
|
export ANDROID_SDK_ROOT="$sdk_dir"
|
||||||
|
export PATH="\$JAVA_HOME/bin:$sdk_dir/cmdline-tools/latest/bin:$sdk_dir/platform-tools:\$PATH"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6. Resumen final
|
||||||
|
echo ""
|
||||||
|
echo "Android SDK instalado en: $sdk_dir"
|
||||||
|
echo "JDK 17: $JAVA_HOME"
|
||||||
|
echo "Para activar: source $sdk_dir/env.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_android_sdk
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: install_cpp_deps
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_cpp_deps() -> void"
|
||||||
|
description: "Verifica e instala las dependencias de sistema necesarias para compilar C++ con ImGui (cmake, g++, glfw, mesa)"
|
||||||
|
tags: [cpp, dependencies, setup, cmake, imgui]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_cpp_deps.sh"
|
||||||
|
params: []
|
||||||
|
output: "Instala paquetes faltantes via apt o confirma que todo esta instalado"
|
||||||
|
---
|
||||||
|
|
||||||
|
# install_cpp_deps
|
||||||
|
|
||||||
|
Verifica las dependencias necesarias para el build C++:
|
||||||
|
- `cmake` — sistema de build
|
||||||
|
- `g++` / `build-essential` — compilador
|
||||||
|
- `libglfw3-dev` — windowing (GLFW)
|
||||||
|
- `libgl1-mesa-dev` — OpenGL headers
|
||||||
|
|
||||||
|
Tambien reporta si `mingw-w64` esta disponible para cross-compile a Windows.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn run install_cpp_deps
|
||||||
|
```
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[install_cpp_deps] Checking C++ build dependencies..."
|
||||||
|
|
||||||
|
MISSING=()
|
||||||
|
|
||||||
|
if ! command -v cmake &>/dev/null; then
|
||||||
|
MISSING+=(cmake)
|
||||||
|
else
|
||||||
|
echo " cmake: $(cmake --version | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v g++ &>/dev/null; then
|
||||||
|
MISSING+=(g++ build-essential)
|
||||||
|
else
|
||||||
|
echo " g++: $(g++ --version | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! dpkg -s libglfw3-dev &>/dev/null 2>&1; then
|
||||||
|
MISSING+=(libglfw3-dev)
|
||||||
|
else
|
||||||
|
echo " libglfw3-dev: installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! dpkg -s libgl1-mesa-dev &>/dev/null 2>&1; then
|
||||||
|
MISSING+=(libgl1-mesa-dev)
|
||||||
|
else
|
||||||
|
echo " libgl1-mesa-dev: installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional: mingw for cross-compile
|
||||||
|
if command -v x86_64-w64-mingw32-g++ &>/dev/null; then
|
||||||
|
echo " mingw-w64: $(x86_64-w64-mingw32-g++ --version | head -1)"
|
||||||
|
else
|
||||||
|
echo " mingw-w64: not installed (optional, for Windows cross-compile)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#MISSING[@]} -eq 0 ]; then
|
||||||
|
echo "[install_cpp_deps] All dependencies satisfied."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[install_cpp_deps] Missing packages: ${MISSING[*]}"
|
||||||
|
echo "[install_cpp_deps] Installing..."
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq "${MISSING[@]}"
|
||||||
|
echo "[install_cpp_deps] Done."
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: install_mantine
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_mantine(project_dir: string) -> void"
|
||||||
|
description: "Instala Mantine UI con todas sus dependencias (@mantine/core, hooks, charts, notifications, form) y PostCSS en un proyecto frontend. Detecta package manager por lockfile. Genera postcss.config.cjs si no existe. Idempotente."
|
||||||
|
tags: [mantine, frontend, install, react, ui, postcss]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio del proyecto frontend con package.json"
|
||||||
|
output: "sin salida; muestra progreso de instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_mantine.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar Mantine en un proyecto con pnpm
|
||||||
|
source install_mantine.sh
|
||||||
|
install_mantine ./apps/rapid_dashboards/frontend
|
||||||
|
|
||||||
|
# Uso directo
|
||||||
|
bash install_mantine.sh ./frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Detecta el package manager por lockfile: pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm. Instala las dependencias core de Mantine v7+ y el stack PostCSS necesario. Si postcss.config.cjs ya existe no lo sobreescribe.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# install_mantine
|
||||||
|
# ---------------
|
||||||
|
# Instala dependencias de Mantine UI en un proyecto frontend.
|
||||||
|
# Detecta package manager por lockfile (pnpm > yarn > npm).
|
||||||
|
# Genera postcss.config.cjs si no existe.
|
||||||
|
# Idempotente: no reinstala si ya estan presentes.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source install_mantine.sh
|
||||||
|
# install_mantine /path/to/frontend
|
||||||
|
#
|
||||||
|
# USO (directo):
|
||||||
|
# bash install_mantine.sh /path/to/frontend
|
||||||
|
|
||||||
|
install_mantine() {
|
||||||
|
local project_dir="$1"
|
||||||
|
|
||||||
|
if [ -z "$project_dir" ]; then
|
||||||
|
echo "install_mantine: se requiere project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$project_dir/package.json" ]; then
|
||||||
|
echo "install_mantine: no existe package.json en $project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detectar package manager
|
||||||
|
local pm="npm"
|
||||||
|
local add_cmd="install"
|
||||||
|
local add_dev_flag="--save-dev"
|
||||||
|
if [ -f "$project_dir/pnpm-lock.yaml" ] || [ -f "$project_dir/pnpm-workspace.yaml" ]; then
|
||||||
|
pm="pnpm"
|
||||||
|
add_cmd="add"
|
||||||
|
add_dev_flag="-D"
|
||||||
|
elif [ -f "$project_dir/yarn.lock" ]; then
|
||||||
|
pm="yarn"
|
||||||
|
add_cmd="add"
|
||||||
|
add_dev_flag="--dev"
|
||||||
|
elif [ -f "$project_dir/package-lock.json" ]; then
|
||||||
|
pm="npm"
|
||||||
|
add_cmd="install"
|
||||||
|
add_dev_flag="--save-dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Detectado package manager: $pm"
|
||||||
|
|
||||||
|
# Dependencias runtime
|
||||||
|
local runtime_deps="@mantine/core @mantine/hooks @mantine/charts @mantine/notifications @mantine/form"
|
||||||
|
echo "Instalando dependencias Mantine..."
|
||||||
|
(cd "$project_dir" && $pm $add_cmd $runtime_deps 2>&1)
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "install_mantine: fallo instalando dependencias runtime" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dependencias PostCSS (dev)
|
||||||
|
local dev_deps="postcss postcss-preset-mantine postcss-simple-vars"
|
||||||
|
echo "Instalando dependencias PostCSS..."
|
||||||
|
(cd "$project_dir" && $pm $add_cmd $add_dev_flag $dev_deps 2>&1)
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "install_mantine: fallo instalando dependencias PostCSS" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generar postcss.config.cjs si no existe
|
||||||
|
if [ ! -f "$project_dir/postcss.config.cjs" ]; then
|
||||||
|
echo "Generando postcss.config.cjs..."
|
||||||
|
cat > "$project_dir/postcss.config.cjs" << 'POSTCSS'
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
POSTCSS
|
||||||
|
echo "postcss.config.cjs creado"
|
||||||
|
else
|
||||||
|
echo "postcss.config.cjs ya existe, no se sobreescribe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Mantine instalado correctamente en $project_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
install_mantine "$@"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: install_nbconvert
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_nbconvert(project_dir: string) -> void"
|
||||||
|
description: "Instala nbconvert y playwright con chromium en un proyecto uv existente. Idempotente: uv add no reinstala si los paquetes ya estan presentes."
|
||||||
|
tags: [jupyter, nbconvert, pdf, export, playwright, python, uv]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio del proyecto con venv existente"
|
||||||
|
output: "sin salida"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_nbconvert.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source install_nbconvert.sh
|
||||||
|
install_nbconvert /home/lucas/analysis/finanzas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere que el venv ya exista (usa `init_uv_venv` antes). La instalacion de chromium via `uv run playwright install chromium` puede tardar la primera vez. La salida de playwright se suprime si tiene exito — solo se muestra si hay un error.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# install_nbconvert
|
||||||
|
# ------------------
|
||||||
|
# Instala nbconvert y playwright con chromium en un proyecto uv existente.
|
||||||
|
# Idempotente: uv add no reinstala si los paquetes ya estan presentes.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source install_nbconvert.sh
|
||||||
|
# install_nbconvert /path/to/project
|
||||||
|
|
||||||
|
install_nbconvert() {
|
||||||
|
local project_dir="$1"
|
||||||
|
|
||||||
|
if [ -z "$project_dir" ]; then
|
||||||
|
echo "install_nbconvert: se requiere project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$project_dir/.venv" ]; then
|
||||||
|
echo "install_nbconvert: no existe .venv en $project_dir — ejecuta init_uv_venv primero" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Instalar nbconvert y playwright via uv add
|
||||||
|
(cd "$project_dir" && uv add nbconvert playwright 2>&1)
|
||||||
|
|
||||||
|
# Instalar chromium — capturar output, solo mostrar si hay error
|
||||||
|
local playwright_output
|
||||||
|
if ! playwright_output=$(cd "$project_dir" && uv run playwright install chromium 2>&1); then
|
||||||
|
echo "$playwright_output" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: install_nordvpn
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "install_nordvpn() -> void"
|
||||||
|
description: "Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente."
|
||||||
|
tags: [vpn, nordvpn, install, infra, wsl2]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "sin salida; muestra estado de instalación"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/install_nordvpn.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source install_nordvpn.sh
|
||||||
|
install_nordvpn
|
||||||
|
# nordvpn ya instalado: NordVPN Version 3.x.x
|
||||||
|
# — o —
|
||||||
|
# Instalando NordVPN CLI...
|
||||||
|
# NordVPN instalado: NordVPN Version 3.x.x
|
||||||
|
# NOTA: ejecuta 'nordvpn login' para autenticarte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# install_nordvpn
|
||||||
|
# ---------------
|
||||||
|
# Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2).
|
||||||
|
# Configura el repositorio oficial, instala el paquete y habilita el servicio.
|
||||||
|
# Si ya esta instalado, no hace nada.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source install_nordvpn.sh
|
||||||
|
# install_nordvpn
|
||||||
|
|
||||||
|
install_nordvpn() {
|
||||||
|
if command -v nordvpn &>/dev/null; then
|
||||||
|
echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando NordVPN CLI..."
|
||||||
|
|
||||||
|
# Descargar e instalar via script oficial
|
||||||
|
sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1
|
||||||
|
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo "install_nordvpn: fallo la instalacion" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Agregar usuario al grupo nordvpn para evitar sudo
|
||||||
|
sudo usermod -aG nordvpn "$USER" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Habilitar servicio (systemd o manual para WSL2)
|
||||||
|
if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then
|
||||||
|
sudo systemctl enable --now nordvpnd 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# WSL2 sin systemd — levantar daemon manualmente
|
||||||
|
sudo nordvpnd &>/dev/null &
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "NordVPN instalado: $(nordvpn version 2>/dev/null)"
|
||||||
|
echo "NOTA: ejecuta 'nordvpn login' para autenticarte"
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_connect
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_connect(country?: string, city?: string) -> json"
|
||||||
|
description: "Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado."
|
||||||
|
tags: [vpn, nordvpn, connect, infra, network]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: country
|
||||||
|
desc: "país de destino (opcional; default: auto)"
|
||||||
|
- name: city
|
||||||
|
desc: "ciudad de destino (opcional; default: auto)"
|
||||||
|
output: "JSON con ok, server, country, city"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_connect.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_connect.sh
|
||||||
|
|
||||||
|
nordvpn_connect
|
||||||
|
# {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"}
|
||||||
|
|
||||||
|
nordvpn_connect Spain
|
||||||
|
# {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"}
|
||||||
|
|
||||||
|
nordvpn_connect Spain Madrid
|
||||||
|
# {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# nordvpn_connect
|
||||||
|
# ---------------
|
||||||
|
# Conecta a NordVPN. Acepta pais, ciudad o servidor especifico.
|
||||||
|
# Sin argumentos conecta al mejor servidor disponible.
|
||||||
|
# Imprime JSON con el resultado de la conexion.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_connect.sh
|
||||||
|
# nordvpn_connect # mejor servidor
|
||||||
|
# nordvpn_connect Spain # por pais
|
||||||
|
# nordvpn_connect Spain Madrid # por ciudad
|
||||||
|
# nordvpn_connect Spain '#42' # servidor especifico
|
||||||
|
|
||||||
|
nordvpn_connect() {
|
||||||
|
local country="${1:-}"
|
||||||
|
local city="${2:-}"
|
||||||
|
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local args=()
|
||||||
|
[ -n "$country" ] && args+=("$country")
|
||||||
|
[ -n "$city" ] && args+=("$city")
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn connect "${args[@]}" 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then
|
||||||
|
local server
|
||||||
|
server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1)
|
||||||
|
echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}"
|
||||||
|
else
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_disconnect
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_disconnect() -> json"
|
||||||
|
description: "Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado."
|
||||||
|
tags: [vpn, nordvpn, disconnect, infra, network]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "JSON con ok y status"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_disconnect.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_disconnect.sh
|
||||||
|
|
||||||
|
nordvpn_disconnect
|
||||||
|
# {"ok":true,"status":"disconnected"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# nordvpn_disconnect
|
||||||
|
# ------------------
|
||||||
|
# Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok.
|
||||||
|
# Imprime JSON con el resultado.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_disconnect.sh
|
||||||
|
# nordvpn_disconnect
|
||||||
|
|
||||||
|
nordvpn_disconnect() {
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn disconnect 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then
|
||||||
|
echo '{"ok":true,"status":"disconnected"}'
|
||||||
|
else
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_get_ip
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_get_ip() -> json"
|
||||||
|
description: "Obtiene IP publica actual con fallback entre multiples servicios. Indica si la conexion VPN esta activa y el servidor usado."
|
||||||
|
tags: [vpn, nordvpn, ip, infra, network, verification]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "JSON con ok, ip, vpn_connected, vpn_server, source"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_get_ip.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_get_ip.sh
|
||||||
|
|
||||||
|
# Con VPN activa:
|
||||||
|
nordvpn_get_ip
|
||||||
|
# {"ok":true,"ip":"185.x.x.x","vpn_connected":true,"vpn_server":"es42.nordvpn.com","source":"https://api.ipify.org"}
|
||||||
|
|
||||||
|
# Sin VPN:
|
||||||
|
nordvpn_get_ip
|
||||||
|
# {"ok":true,"ip":"88.x.x.x","vpn_connected":false,"vpn_server":"","source":"https://api.ipify.org"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa ipify.org como servicio primario con fallback a ifconfig.me e icanhazip.com. Timeout de 5 segundos por servicio. Util para verificar que el tunel VPN esta activo antes de ejecutar operaciones sensibles a la IP.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# nordvpn_get_ip
|
||||||
|
# --------------
|
||||||
|
# Obtiene la IP publica actual para verificar que el tunel VPN funciona.
|
||||||
|
# Usa multiples servicios como fallback.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_get_ip.sh
|
||||||
|
# nordvpn_get_ip
|
||||||
|
|
||||||
|
nordvpn_get_ip() {
|
||||||
|
local ip=""
|
||||||
|
local source=""
|
||||||
|
|
||||||
|
# Intentar multiples servicios
|
||||||
|
for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com"; do
|
||||||
|
ip=$(curl -s --max-time 5 "$svc" 2>/dev/null)
|
||||||
|
if echo "$ip" | grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
|
||||||
|
source="$svc"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
ip=""
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
echo '{"ok":false,"error":"no se pudo obtener IP publica"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Si nordvpn esta disponible, incluir info de conexion
|
||||||
|
local connected="false"
|
||||||
|
local vpn_server=""
|
||||||
|
if command -v nordvpn &>/dev/null; then
|
||||||
|
local status_output
|
||||||
|
status_output=$(nordvpn status 2>/dev/null)
|
||||||
|
if echo "$status_output" | grep -qi "connected"; then
|
||||||
|
connected="true"
|
||||||
|
vpn_server=$(echo "$status_output" | grep -iP "hostname|server" | head -1 | sed 's/.*: *//')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"ok\":true,\"ip\":\"$ip\",\"vpn_connected\":$connected,\"vpn_server\":\"$vpn_server\",\"source\":\"$source\"}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_list_cities
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_list_cities(country: string) -> json"
|
||||||
|
description: "Lista ciudades disponibles de un pais en NordVPN como array JSON ordenado."
|
||||||
|
tags: [vpn, nordvpn, cities, infra, network]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: country
|
||||||
|
desc: "nombre del país en NordVPN (ej: Spain, United_States)"
|
||||||
|
output: "JSON con ok, country, count, cities (array de strings)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_list_cities.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_list_cities.sh
|
||||||
|
|
||||||
|
nordvpn_list_cities Spain
|
||||||
|
# {"ok":true,"country":"Spain","count":2,"cities":["Barcelona","Madrid"]}
|
||||||
|
|
||||||
|
nordvpn_list_cities "United_States"
|
||||||
|
# {"ok":true,"country":"United_States","count":15,"cities":["Atlanta","Buffalo",...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
El nombre de pais debe coincidir con lo que devuelve `nordvpn countries`. Usa underscores para paises compuestos (ej: United_States). Las ciudades se devuelven con espacios.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# nordvpn_list_cities
|
||||||
|
# -------------------
|
||||||
|
# Lista las ciudades disponibles de un pais en NordVPN como array JSON.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_list_cities.sh
|
||||||
|
# nordvpn_list_cities Spain
|
||||||
|
|
||||||
|
nordvpn_list_cities() {
|
||||||
|
local country="${1:?nordvpn_list_cities: se requiere pais como argumento}"
|
||||||
|
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn cities "$country" 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$output" | python3 -c '
|
||||||
|
import sys, json, re
|
||||||
|
|
||||||
|
country = "'"$country"'"
|
||||||
|
text = sys.stdin.read()
|
||||||
|
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||||
|
text = re.sub(r"[\t\r]", " ", text)
|
||||||
|
cities = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
|
||||||
|
cities = [c for c in cities if len(c) > 1]
|
||||||
|
cities.sort()
|
||||||
|
print(json.dumps({"ok": True, "country": country, "count": len(cities), "cities": cities}))
|
||||||
|
'
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_list_countries
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_list_countries() -> json"
|
||||||
|
description: "Lista paises disponibles en NordVPN como array JSON ordenado alfabeticamente."
|
||||||
|
tags: [vpn, nordvpn, countries, infra, network]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "JSON con ok, count, countries (array de strings ordenado alfabéticamente)"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_list_countries.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_list_countries.sh
|
||||||
|
|
||||||
|
nordvpn_list_countries
|
||||||
|
# {"ok":true,"count":60,"countries":["Albania","Argentina","Australia",...,"United States","Vietnam"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Parsea la salida de `nordvpn countries` eliminando codigos ANSI y normalizando separadores. Los nombres de paises se devuelven con espacios en vez de underscores.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# nordvpn_list_countries
|
||||||
|
# ----------------------
|
||||||
|
# Lista los paises disponibles en NordVPN como array JSON.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_list_countries.sh
|
||||||
|
# nordvpn_list_countries
|
||||||
|
|
||||||
|
nordvpn_list_countries() {
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn countries 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$output" | python3 -c '
|
||||||
|
import sys, json, re
|
||||||
|
|
||||||
|
text = sys.stdin.read()
|
||||||
|
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||||
|
text = re.sub(r"[\t\r]", " ", text)
|
||||||
|
# Split by comma, whitespace, or newline and clean
|
||||||
|
countries = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"]
|
||||||
|
countries = [c for c in countries if len(c) > 1]
|
||||||
|
countries.sort()
|
||||||
|
print(json.dumps({"ok": True, "count": len(countries), "countries": countries}))
|
||||||
|
'
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_set_protocol
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_set_protocol(protocol: string) -> json"
|
||||||
|
description: "Cambia el protocolo de NordVPN entre NordLynx (WireGuard) y OpenVPN. NordLynx recomendado por velocidad."
|
||||||
|
tags: [vpn, nordvpn, protocol, nordlynx, wireguard, openvpn, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: protocol
|
||||||
|
desc: "protocolo a usar: NordLynx (WireGuard) u OpenVPN"
|
||||||
|
output: "JSON con ok y protocol confirmado"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_set_protocol.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_set_protocol.sh
|
||||||
|
|
||||||
|
nordvpn_set_protocol NordLynx
|
||||||
|
# {"ok":true,"protocol":"NordLynx"}
|
||||||
|
|
||||||
|
nordvpn_set_protocol OpenVPN
|
||||||
|
# {"ok":true,"protocol":"OpenVPN"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
NordLynx es WireGuard wrapeado por NordVPN — mas rapido y moderno. OpenVPN es mas compatible con redes restrictivas. El cambio de protocolo requiere reconectar si hay una conexion activa.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# nordvpn_set_protocol
|
||||||
|
# --------------------
|
||||||
|
# Cambia el protocolo de NordVPN (NordLynx o OpenVPN).
|
||||||
|
# NordLynx = WireGuard (recomendado por velocidad).
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_set_protocol.sh
|
||||||
|
# nordvpn_set_protocol NordLynx
|
||||||
|
# nordvpn_set_protocol OpenVPN
|
||||||
|
|
||||||
|
nordvpn_set_protocol() {
|
||||||
|
local protocol="${1:?nordvpn_set_protocol: se requiere protocolo (NordLynx|OpenVPN)}"
|
||||||
|
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$protocol" in
|
||||||
|
NordLynx|nordlynx|NORDLYNX) protocol="NordLynx" ;;
|
||||||
|
OpenVPN|openvpn|OPENVPN) protocol="OpenVPN" ;;
|
||||||
|
*)
|
||||||
|
echo "{\"ok\":false,\"error\":\"protocolo invalido: $protocol (usar NordLynx o OpenVPN)\"}"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn set protocol "$protocol" 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -eq 0 ] || echo "$output" | grep -qi "already set\|successfully"; then
|
||||||
|
echo "{\"ok\":true,\"protocol\":\"$protocol\"}"
|
||||||
|
else
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn_status
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "nordvpn_status() -> json"
|
||||||
|
description: "Obtiene estado actual de NordVPN como JSON estructurado. Incluye servidor, IP, pais, protocolo y estado de conexion."
|
||||||
|
tags: [vpn, nordvpn, status, infra, network]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "JSON con estado de VPN: ok, connected, status, hostname, ip, country, city, etc"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/nordvpn_status.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source nordvpn_status.sh
|
||||||
|
|
||||||
|
nordvpn_status
|
||||||
|
# {"ok":true,"connected":true,"status":"Connected","hostname":"es42.nordvpn.com","ip":"185.x.x.x","country":"Spain","city":"Madrid","current_technology":"NordLynx","current_protocol":"nordlynx","transfer":"1.2 MiB received, 500 KiB sent","uptime":"5 minutes 32 seconds"}
|
||||||
|
|
||||||
|
# Desconectado:
|
||||||
|
# {"ok":true,"connected":false,"status":"Disconnected"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Parsea la salida clave-valor de `nordvpn status` eliminando codigos ANSI. Los campos disponibles dependen del estado de conexion — cuando esta desconectado solo devuelve status y connected.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# nordvpn_status
|
||||||
|
# --------------
|
||||||
|
# Obtiene el estado actual de NordVPN como JSON estructurado.
|
||||||
|
# Parsea la salida clave-valor de `nordvpn status` a campos JSON.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source nordvpn_status.sh
|
||||||
|
# nordvpn_status
|
||||||
|
|
||||||
|
nordvpn_status() {
|
||||||
|
if ! command -v nordvpn &>/dev/null; then
|
||||||
|
echo '{"ok":false,"error":"nordvpn no instalado"}' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(nordvpn status 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parsear output clave: valor a JSON con python3
|
||||||
|
echo "$output" | python3 -c '
|
||||||
|
import sys, json, re
|
||||||
|
|
||||||
|
lines = sys.stdin.read().strip().split("\n")
|
||||||
|
data = {"ok": True}
|
||||||
|
for line in lines:
|
||||||
|
line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip()
|
||||||
|
line = line.lstrip("- ")
|
||||||
|
if ":" in line:
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
key = key.strip().lower().replace(" ", "_")
|
||||||
|
val = val.strip()
|
||||||
|
if key == "status":
|
||||||
|
data["connected"] = val.lower() == "connected"
|
||||||
|
data[key] = val
|
||||||
|
print(json.dumps(data))
|
||||||
|
'
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: notebook_to_pdf
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "notebook_to_pdf(project_dir: string, [pattern: string], [output_dir: string]) -> string"
|
||||||
|
description: "Convierte notebooks Jupyter a PDF usando nbconvert webpdf con chromium. Lista los PDFs generados al finalizar."
|
||||||
|
tags: [jupyter, notebook, pdf, export, nbconvert, playwright]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio raíz del proyecto con venv y notebooks"
|
||||||
|
- name: pattern
|
||||||
|
desc: "glob de notebooks a convertir (default: notebooks/*.ipynb)"
|
||||||
|
- name: output_dir
|
||||||
|
desc: "directorio destino para PDFs relativo a project_dir (default: notebooks/pdf/)"
|
||||||
|
output: "lista de PDFs generados con sus rutas"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/notebook_to_pdf.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source notebook_to_pdf.sh
|
||||||
|
|
||||||
|
# Con defaults (notebooks/*.ipynb -> notebooks/pdf/)
|
||||||
|
notebook_to_pdf /home/lucas/analysis/finanzas
|
||||||
|
|
||||||
|
# Con pattern y output_dir custom
|
||||||
|
notebook_to_pdf /home/lucas/analysis/finanzas "notebooks/01_*.ipynb" "exports/pdf/"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere nbconvert y playwright con chromium instalados (usa `install_nbconvert` antes). Usa el venv del proyecto directamente (`.venv/bin/jupyter`). El output_dir es relativo a project_dir. Imprime los PDFs generados con sus rutas al finalizar. Falla si no se genera ningun PDF.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# notebook_to_pdf
|
||||||
|
# ----------------
|
||||||
|
# Convierte notebooks Jupyter a PDF usando nbconvert webpdf.
|
||||||
|
# Requiere nbconvert y playwright con chromium instalados.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source notebook_to_pdf.sh
|
||||||
|
# notebook_to_pdf /path/to/project
|
||||||
|
# notebook_to_pdf /path/to/project "notebooks/*.ipynb" "notebooks/pdf/"
|
||||||
|
|
||||||
|
notebook_to_pdf() {
|
||||||
|
local project_dir="$1"
|
||||||
|
local pattern="${2:-notebooks/*.ipynb}"
|
||||||
|
local output_dir="${3:-notebooks/pdf/}"
|
||||||
|
|
||||||
|
if [ -z "$project_dir" ]; then
|
||||||
|
echo "notebook_to_pdf: se requiere project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$project_dir/.venv" ]; then
|
||||||
|
echo "notebook_to_pdf: no existe .venv en $project_dir" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crear directorio de salida si no existe
|
||||||
|
mkdir -p "$project_dir/$output_dir"
|
||||||
|
|
||||||
|
# Convertir notebooks a PDF con nbconvert webpdf
|
||||||
|
# nbconvert puede retornar exit != 0 por warnings de validacion JSON
|
||||||
|
# que no impiden la generacion del PDF, asi que ignoramos el exit code
|
||||||
|
# y verificamos que los PDFs se hayan generado
|
||||||
|
local nbconvert_output
|
||||||
|
nbconvert_output=$(cd "$project_dir" && \
|
||||||
|
.venv/bin/jupyter nbconvert \
|
||||||
|
--to webpdf \
|
||||||
|
--allow-chromium-download \
|
||||||
|
--output-dir="$output_dir" \
|
||||||
|
$pattern 2>&1) || true
|
||||||
|
|
||||||
|
echo "$nbconvert_output"
|
||||||
|
|
||||||
|
# Listar PDFs generados
|
||||||
|
echo ""
|
||||||
|
echo "PDFs generados en ${project_dir}/${output_dir}:"
|
||||||
|
local pdf_count=0
|
||||||
|
while IFS= read -r -d '' pdf; do
|
||||||
|
echo " $pdf"
|
||||||
|
pdf_count=$((pdf_count + 1))
|
||||||
|
done < <(find "$project_dir/$output_dir" -name "*.pdf" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$pdf_count" -eq 0 ]; then
|
||||||
|
echo " (ninguno encontrado — nbconvert pudo haber fallado)" >&2
|
||||||
|
echo "$nbconvert_output" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Total: $pdf_count PDFs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: pass_delete
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_delete(entry: string) -> void"
|
||||||
|
description: "Elimina un secreto del password store (pass)."
|
||||||
|
tags: [pass, secret, credential, delete]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: entry
|
||||||
|
desc: "ruta de entrada en el password store (ej: agentes/token)"
|
||||||
|
output: "sin salida"
|
||||||
|
tested: true
|
||||||
|
tests: ["elimina entrada de test", "falla con entrada inexistente"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_delete.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_delete.sh
|
||||||
|
pass_delete agentes/viejo-token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `pass rm -f` para eliminar sin prompt de confirmacion.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# pass_delete
|
||||||
|
# -----------
|
||||||
|
# Elimina un secreto del password store.
|
||||||
|
# Sale con exit code 1 si la entrada no existe o pass falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_delete.sh
|
||||||
|
# pass_delete agentes/viejo-token
|
||||||
|
|
||||||
|
pass_delete() {
|
||||||
|
local entry="$1"
|
||||||
|
|
||||||
|
if [ -z "$entry" ]; then
|
||||||
|
echo "pass_delete: se requiere nombre de entrada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pass rm -f "$entry" >/dev/null 2>&1; then
|
||||||
|
echo "pass_delete: fallo al eliminar '$entry'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: pass_generate
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_generate(entry: string, [length: int]) -> string"
|
||||||
|
description: "Genera un password aleatorio, lo almacena en el password store e imprime el valor generado."
|
||||||
|
tags: [pass, secret, credential, generate, random]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: entry
|
||||||
|
desc: "ruta de entrada en el password store"
|
||||||
|
- name: length
|
||||||
|
desc: "longitud del password (default: 24 caracteres)"
|
||||||
|
output: "password generado en texto plano"
|
||||||
|
tested: true
|
||||||
|
tests: ["genera password de longitud especifica", "default 24 chars"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_generate.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_generate.sh
|
||||||
|
new_pass=$(pass_generate agentes/nuevo-servicio 32)
|
||||||
|
echo "password generado: $new_pass"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `pass generate -f -n` (force overwrite, no symbols). Default 24 caracteres alfanumericos.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# pass_generate
|
||||||
|
# -------------
|
||||||
|
# Genera un password aleatorio y lo almacena en el password store.
|
||||||
|
# Imprime el password generado a stdout.
|
||||||
|
# Sale con exit code 1 si pass falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_generate.sh
|
||||||
|
# pass_generate agentes/nuevo-token 32
|
||||||
|
# pass_generate agentes/api-key # default 24 chars
|
||||||
|
|
||||||
|
pass_generate() {
|
||||||
|
local entry="$1"
|
||||||
|
local length="${2:-24}"
|
||||||
|
|
||||||
|
if [ -z "$entry" ]; then
|
||||||
|
echo "pass_generate: se requiere nombre de entrada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output
|
||||||
|
output=$(pass generate -f -n "$entry" "$length" 2>&1)
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_generate: fallo al generar '$entry': $output" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pass generate imprime ANSI escape codes + header + password
|
||||||
|
# Extraer ultima linea y limpiar escape codes
|
||||||
|
echo "$output" | tail -1 | sed 's/\x1b\[[0-9;]*m//g'
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: pass_get
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_get(entry: string) -> string"
|
||||||
|
description: "Lee un secreto del password store (pass) y lo imprime a stdout."
|
||||||
|
tags: [pass, secret, credential, get]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: entry
|
||||||
|
desc: "ruta de entrada en el password store"
|
||||||
|
output: "valor del secreto en texto plano"
|
||||||
|
tested: true
|
||||||
|
tests: ["lee entrada existente", "falla con entrada inexistente"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_get.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_get.sh
|
||||||
|
token=$(pass_get agentes/dataforge-token)
|
||||||
|
export GITEA_TOKEN="$token"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `pass show` internamente. Requiere GPG key desbloqueada. No imprime newline final (usa printf %s).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# pass_get
|
||||||
|
# --------
|
||||||
|
# Lee un secreto del password store y lo imprime a stdout.
|
||||||
|
# Sale con exit code 1 si la entrada no existe o pass falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_get.sh
|
||||||
|
# token=$(pass_get agentes/dataforge-token)
|
||||||
|
|
||||||
|
pass_get() {
|
||||||
|
local entry="$1"
|
||||||
|
|
||||||
|
if [ -z "$entry" ]; then
|
||||||
|
echo "pass_get: se requiere nombre de entrada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local value
|
||||||
|
value=$(pass show "$entry" 2>/dev/null)
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_get: no se pudo leer '$entry'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: pass_list
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_list([prefix: string]) -> json"
|
||||||
|
description: "Lista entradas del password store como JSON array. Filtra opcionalmente por prefijo."
|
||||||
|
tags: [pass, secret, credential, list]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: prefix
|
||||||
|
desc: "prefijo para filtrar entradas (opcional; ej: agentes)"
|
||||||
|
output: "JSON array de nombres de entradas"
|
||||||
|
tested: true
|
||||||
|
tests: ["lista todas las entradas", "filtra por prefijo"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_list.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_list.sh
|
||||||
|
entries=$(pass_list agentes)
|
||||||
|
# ["dataforge-token","egutierrez-token","gitea-url"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Parsea el output tree de `pass ls` y lo convierte a JSON array. Cada entrada es un string con el nombre relativo al prefijo.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# pass_list
|
||||||
|
# ---------
|
||||||
|
# Lista entradas del password store como JSON array.
|
||||||
|
# Opcionalmente filtra por prefijo.
|
||||||
|
# Sale con exit code 1 si pass falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_list.sh
|
||||||
|
# pass_list # todas las entradas
|
||||||
|
# pass_list agentes # solo bajo agentes/
|
||||||
|
|
||||||
|
pass_list() {
|
||||||
|
local prefix="${1:-}"
|
||||||
|
|
||||||
|
local raw
|
||||||
|
raw=$(pass ls "$prefix" 2>/dev/null)
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_list: fallo al listar entradas" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parsear output de pass: extraer nombres limpios (sin tree chars)
|
||||||
|
local entries
|
||||||
|
entries=$(echo "$raw" | sed 's/[│├└──── ]//g' | sed '/^$/d' | grep -v '^Password' | grep -v '^[[:space:]]*$')
|
||||||
|
|
||||||
|
# Construir JSON array
|
||||||
|
printf '['
|
||||||
|
local first=true
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line=$(echo "$line" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
if [ "$first" = true ]; then
|
||||||
|
first=false
|
||||||
|
else
|
||||||
|
printf ','
|
||||||
|
fi
|
||||||
|
printf '"%s"' "$line"
|
||||||
|
done <<< "$entries"
|
||||||
|
printf ']'
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: pass_set
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_set(entry: string, [value: string]) -> void"
|
||||||
|
description: "Inserta o sobreescribe un secreto en el password store (pass)."
|
||||||
|
tags: [pass, secret, credential, set, insert]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: entry
|
||||||
|
desc: "ruta de entrada en el password store"
|
||||||
|
- name: value
|
||||||
|
desc: "valor del secreto (opcional; se lee de stdin si no se proporciona)"
|
||||||
|
output: "sin salida"
|
||||||
|
tested: true
|
||||||
|
tests: ["inserta valor y lo lee de vuelta", "sobreescribe valor existente"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_set.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_set.sh
|
||||||
|
pass_set agentes/nuevo-servicio "token-abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `pass insert -m -f` para forzar sobreescritura sin prompt interactivo. Si no se pasa valor como argumento, lee de stdin.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# pass_set
|
||||||
|
# --------
|
||||||
|
# Inserta o sobreescribe un secreto en el password store.
|
||||||
|
# Lee el valor de stdin si no se pasa como segundo argumento.
|
||||||
|
# Sale con exit code 1 si pass falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_set.sh
|
||||||
|
# pass_set agentes/nuevo-token "mi-token-secreto"
|
||||||
|
# echo "mi-token" | pass_set agentes/nuevo-token
|
||||||
|
|
||||||
|
pass_set() {
|
||||||
|
local entry="$1"
|
||||||
|
local value="$2"
|
||||||
|
|
||||||
|
if [ -z "$entry" ]; then
|
||||||
|
echo "pass_set: se requiere nombre de entrada" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$value" ]; then
|
||||||
|
printf '%s' "$value" | pass insert -m -f "$entry" >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
pass insert -m -f "$entry" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_set: fallo al escribir '$entry'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: pass_sync
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "pass_sync() -> json"
|
||||||
|
description: "Sincroniza el password store con el repositorio git remoto (pull + push)."
|
||||||
|
tags: [pass, secret, sync, git]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params: []
|
||||||
|
output: "JSON con resultados de pull y push"
|
||||||
|
tested: true
|
||||||
|
tests: ["sincroniza con remoto"]
|
||||||
|
test_file_path: "bash/functions/infra/pass_test.sh"
|
||||||
|
file_path: "bash/functions/infra/pass_sync.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source pass_sync.sh
|
||||||
|
result=$(pass_sync)
|
||||||
|
# {"pull":"Already up to date.","push":"Everything up-to-date"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `pass git pull` seguido de `pass git push`. Requiere que el password store tenga un remote git configurado. Retorna JSON con la ultima linea de cada operacion.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# pass_sync
|
||||||
|
# ---------
|
||||||
|
# Sincroniza el password store con el repositorio git remoto (pull + push).
|
||||||
|
# Sale con exit code 1 si la sincronizacion falla.
|
||||||
|
#
|
||||||
|
# USO (sourced):
|
||||||
|
# source pass_sync.sh
|
||||||
|
# pass_sync
|
||||||
|
|
||||||
|
pass_sync() {
|
||||||
|
local pull_out
|
||||||
|
pull_out=$(pass git pull 2>&1)
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_sync: fallo en git pull: $pull_out" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local push_out
|
||||||
|
push_out=$(pass git push 2>&1)
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "pass_sync: fallo en git push: $push_out" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"pull":"%s","push":"%s"}' \
|
||||||
|
"$(echo "$pull_out" | tail -1 | sed 's/"/\\"/g')" \
|
||||||
|
"$(echo "$push_out" | tail -1 | sed 's/"/\\"/g')"
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# pass_test.sh — Tests para funciones pass del registry
|
||||||
|
# Usa la entrada test/fn_registry_test como sandbox (se limpia al final)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/pass_get.sh"
|
||||||
|
source "$SCRIPT_DIR/pass_set.sh"
|
||||||
|
source "$SCRIPT_DIR/pass_list.sh"
|
||||||
|
source "$SCRIPT_DIR/pass_delete.sh"
|
||||||
|
source "$SCRIPT_DIR/pass_generate.sh"
|
||||||
|
source "$SCRIPT_DIR/pass_sync.sh"
|
||||||
|
|
||||||
|
TEST_ENTRY="test/fn_registry_test"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass_cleanup() {
|
||||||
|
pass rm -f "$TEST_ENTRY" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local test_name="$1" got="$2" want="$3"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
echo " PASS: $test_name"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo " FAIL: $test_name (got='$got', want='$want')"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local test_name="$1" got="$2" want="$3"
|
||||||
|
if echo "$got" | grep -q "$want"; then
|
||||||
|
echo " PASS: $test_name"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo " FAIL: $test_name (got='$got', want contener '$want')"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_nonzero() {
|
||||||
|
local test_name="$1" got="$2"
|
||||||
|
if [ -n "$got" ]; then
|
||||||
|
echo " PASS: $test_name"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo " FAIL: $test_name (got vacio)"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_fail() {
|
||||||
|
local test_name="$1"
|
||||||
|
shift
|
||||||
|
set +e
|
||||||
|
"$@" 2>/dev/null
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
echo " FAIL: $test_name (esperaba fallo pero exitoso)"
|
||||||
|
((FAIL++))
|
||||||
|
else
|
||||||
|
echo " PASS: $test_name"
|
||||||
|
((PASS++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-check
|
||||||
|
if ! command -v pass &>/dev/null; then
|
||||||
|
echo "SKIP: pass no disponible"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap pass_cleanup EXIT
|
||||||
|
|
||||||
|
echo "=== pass_get ==="
|
||||||
|
|
||||||
|
echo " test: lee entrada existente (agentes/gitea-url)"
|
||||||
|
got=$(pass_get agentes/gitea-url)
|
||||||
|
assert_nonzero "lee entrada existente" "$got"
|
||||||
|
|
||||||
|
echo " test: falla con entrada inexistente"
|
||||||
|
assert_fail "falla con entrada inexistente" pass_get "no/existe/xyz"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== pass_set ==="
|
||||||
|
|
||||||
|
echo " test: inserta valor y lo lee de vuelta"
|
||||||
|
pass_set "$TEST_ENTRY" "test-value-12345"
|
||||||
|
got=$(pass_get "$TEST_ENTRY")
|
||||||
|
assert_eq "inserta y lee" "$got" "test-value-12345"
|
||||||
|
|
||||||
|
echo " test: sobreescribe valor existente"
|
||||||
|
pass_set "$TEST_ENTRY" "overwritten-value"
|
||||||
|
got=$(pass_get "$TEST_ENTRY")
|
||||||
|
assert_eq "sobreescribe" "$got" "overwritten-value"
|
||||||
|
|
||||||
|
# Limpiar para siguiente test
|
||||||
|
pass_cleanup
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== pass_list ==="
|
||||||
|
|
||||||
|
echo " test: lista todas las entradas"
|
||||||
|
got=$(pass_list)
|
||||||
|
assert_contains "lista todas" "$got" "dataforge-token"
|
||||||
|
|
||||||
|
echo " test: filtra por prefijo agentes"
|
||||||
|
got=$(pass_list agentes)
|
||||||
|
assert_contains "filtra agentes" "$got" "gitea-url"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== pass_generate ==="
|
||||||
|
|
||||||
|
echo " test: genera password de 16 chars"
|
||||||
|
generated=$(pass_generate "$TEST_ENTRY" 16)
|
||||||
|
assert_eq "longitud 16" "${#generated}" "16"
|
||||||
|
|
||||||
|
echo " test: valor almacenado coincide"
|
||||||
|
stored=$(pass_get "$TEST_ENTRY")
|
||||||
|
assert_eq "stored = generated" "$stored" "$generated"
|
||||||
|
|
||||||
|
pass_cleanup
|
||||||
|
|
||||||
|
echo " test: default 24 chars"
|
||||||
|
generated=$(pass_generate "$TEST_ENTRY")
|
||||||
|
assert_eq "longitud default 24" "${#generated}" "24"
|
||||||
|
|
||||||
|
pass_cleanup
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== pass_delete ==="
|
||||||
|
|
||||||
|
echo " test: elimina entrada de test"
|
||||||
|
pass_set "$TEST_ENTRY" "to-delete"
|
||||||
|
pass_delete "$TEST_ENTRY"
|
||||||
|
assert_fail "despues de delete no se puede leer" pass_get "$TEST_ENTRY"
|
||||||
|
|
||||||
|
echo " test: falla con entrada inexistente"
|
||||||
|
assert_fail "delete inexistente" pass_delete "no/existe/xyz_delete_test"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== pass_sync ==="
|
||||||
|
|
||||||
|
echo " test: sincroniza con remoto"
|
||||||
|
got=$(pass_sync)
|
||||||
|
assert_contains "sync retorna json" "$got" "pull"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================================"
|
||||||
|
echo "Resultados: $PASS passed, $FAIL failed"
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: uv_add_packages
|
||||||
|
kind: function
|
||||||
|
lang: bash
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "uv_add_packages(project_dir: string, ...packages: string) -> void"
|
||||||
|
description: "Instala paquetes Python en un proyecto usando uv add con fallback a pip. Inicializa pyproject.toml si no existe."
|
||||||
|
tags: [python, uv, pip, packages, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
params:
|
||||||
|
- name: project_dir
|
||||||
|
desc: "directorio del proyecto con venv existente"
|
||||||
|
- name: packages
|
||||||
|
desc: "nombres de paquetes Python a instalar (variadic)"
|
||||||
|
output: "sin salida"
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "bash/functions/infra/uv_add_packages.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source uv_add_packages.sh
|
||||||
|
uv_add_packages /home/lucas/analysis/finanzas jupyter jupyterlab pandas numpy
|
||||||
|
|
||||||
|
# Solo un paquete
|
||||||
|
uv_add_packages . polars
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Requiere que el venv ya exista (usa `init_uv_venv` antes). Prefiere uv por velocidad y reproducibilidad (lockfile). Si uv no esta disponible, usa pip del venv directamente.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user