diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 913894c0..bb382fd6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,9 +3,11 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes. **Dos bases de datos SQLite:** -- **registry.db** (raiz) — funciones, tipos, proposals. Regenerable con `fn index` (excepto proposals). +- **registry.db** (raiz) — funciones, tipos, proposals, apps, projects, analysis, vaults, pc_locations. Regenerable con `fn index` (excepto proposals y pc_locations). - **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos. +**Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token). + **Reglas y convenciones:** ver `.claude/rules/INDEX.md` --- @@ -66,6 +68,13 @@ sqlite3 registry.db ".schema" - Extraidos automaticamente por `fn index` desde los archivos de test - FK: `function_id` → `functions.id` +**pc_locations** — columnas: `id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at` +- Mapa de ubicaciones por PC: donde esta cada app/analysis/project/vault en cada maquina +- `entity_type`: app, analysis, project, vault +- `status`: active, missing, archived +- Se puebla con `fn sync`, NO con `fn index` +- Consultas: `SELECT * FROM pc_locations WHERE pc_id = 'home-wsl'` + **FTS5 (columnas buscables):** - `functions_fts`: id, name, description, tags, signature, domain, example, notes, documentation, code, params_schema - `types_fts`: id, name, description, tags, domain, examples, notes, documentation, code @@ -141,6 +150,13 @@ fn proposal list [-k kind] [-s status] fn proposal show fn proposal update --status approved [--reviewed-by lucas] +# Sync entre PCs +fn sync # Push+pull completo contra el servidor +fn sync status # Estado local: PC, API, conteos +fn sync locations # Mapa de ubicaciones en todos los PCs +# Config: ~/.fn_pc (identidad PC), FN_REGISTRY_API (URL), REGISTRY_API_TOKEN (token) +# URL con basicAuth: export FN_REGISTRY_API="https://user:pass@registry.organic-machine.com" + # Operations (desde directorio con operations.db) fn ops init [path] fn ops entity add|list|show|delete diff --git a/bash/functions/infra/setup_registry_api.md b/bash/functions/infra/setup_registry_api.md new file mode 100644 index 00000000..8922e183 --- /dev/null +++ b/bash/functions/infra/setup_registry_api.md @@ -0,0 +1,85 @@ +--- +name: setup_registry_api +kind: pipeline +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json" +description: "Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check." +tags: [launcher, deploy, docker, traefik, registry, coolify, infra, ssh] +uses_functions: [rsync_deploy_bash_infra, ssh_exec_go_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: ssh_host + desc: "Alias SSH del VPS destino definido en ~/.ssh/config (default: organic-machine.com)" + - name: api_token + desc: "Token de autenticación para la registry_api (REGISTRY_API_TOKEN). Se escribe en el .env remoto." + - name: basic_auth_user + desc: "Usuario para basicAuth de Traefik (default: lucas). Se usa para generar el hash bcrypt con htpasswd." + - name: basic_auth_pass + desc: "Password para basicAuth de Traefik. Se hashea con bcrypt (htpasswd -nB -C 10) y se escapa a $$ para Traefik." +output: "JSON con status (ok|error), url del servicio, http_code del health check, duration_ms, ssh_host y remote_dir. Exit code 1 si algún paso falla." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/setup_registry_api.sh" +--- + +## Requisitos previos + +- `htpasswd` instalado localmente (`apt install apache2-utils`) +- `rsync` instalado localmente +- SSH alias `organic-machine.com` (o el host indicado) configurado en `~/.ssh/config` +- El usuario SSH debe tener `sudo` sin password para `mkdir -p /data/coolify/proxy/dynamic/` y `tee` en esa ruta +- Red Docker `coolify` existente en el VPS (se crea automáticamente si no existe) +- Traefik corriendo con file watcher en `/data/coolify/proxy/dynamic/` (Coolify proxy estándar) + +## Ejemplo + +```bash +# Forma directa como script +bash bash/functions/infra/setup_registry_api.sh \ + organic-machine.com \ + "mi-token-secreto" \ + lucas \ + "mi-password" + +# Como función sourced +source bash/functions/infra/setup_registry_api.sh + +result=$(setup_registry_api \ + "organic-machine.com" \ + "mi-token-secreto" \ + "lucas" \ + "mi-password") +echo "$result" +# {"status":"ok","url":"https://registry.organic-machine.com/api/status","http_code":"200","duration_ms":45231,"ssh_host":"organic-machine.com","remote_dir":"/opt/fn-registry-build/apps/registry_api"} + +# Via variables de entorno +export REGISTRY_API_TOKEN="mi-token-secreto" +export BASIC_AUTH_PASS="mi-password" +bash bash/functions/infra/setup_registry_api.sh +``` + +## Pasos del pipeline + +1. **Verificar SSH** — `ssh -o BatchMode=yes -o ConnectTimeout=10` para confirmar acceso al VPS +2. **Generar hash bcrypt** — `htpasswd -nB -C 10` localmente, escapar `$` a `$$` para Traefik +3. **rsync del repo** — sube el repo completo a `/opt/fn-registry-build/` en el VPS (el Dockerfile necesita el contexto raíz) +4. **Subir traefik-dynamic.yml** — reemplaza el placeholder del hash en el template local y lo sube a `/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml` via `sudo tee` +5. **Crear .env** — escribe `REGISTRY_API_TOKEN=...` en `apps/registry_api/.env` en el VPS +6. **docker compose build && up -d** — construye la imagen (multi-stage, CGO+FTS5) y levanta el container con la red `coolify` +7. **Health check** — polling a `https://registry.organic-machine.com/api/status` cada 10s, máximo 12 intentos (2 minutos) + +## Notas + +- El `docker-compose.yml` de la app usa `context: ../../` para incluir `registry/`, `functions/`, `cmd/` y `apps/registry_api/` en el build. Por eso se sincroniza el repo completo y no solo la app. +- El Dockerfile genera el binario `registry_api` con `CGO_ENABLED=1 -tags fts5` (SQLite + FTS5). El `registry.db` se genera en el primer arranque via `fn index` dentro del container, o puede montarse externamente via el volumen `/data`. +- Traefik detecta el cambio en `/data/coolify/proxy/dynamic/` automáticamente (file provider con file watcher), sin necesidad de reiniciar Traefik. +- Para re-deploys: ejecutar el mismo script — rsync es idempotente y `docker compose up -d` recrea el container si la imagen cambió. +- Si `REGISTRY_API_TOKEN` está vacío, la API arranca sin autenticación (solo basicAuth de Traefik protege el acceso). diff --git a/bash/functions/infra/setup_registry_api.sh b/bash/functions/infra/setup_registry_api.sh new file mode 100644 index 00000000..6516ee0d --- /dev/null +++ b/bash/functions/infra/setup_registry_api.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# setup_registry_api — Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)" + +source "$SCRIPT_DIR/rsync_deploy.sh" + +setup_registry_api() { + local ssh_host="${1:-organic-machine.com}" + local api_token="${2:-}" + local basic_auth_user="${3:-lucas}" + local basic_auth_pass="${4:-}" + + if [[ -z "$api_token" ]]; then + echo "setup_registry_api: REGISTRY_API_TOKEN es obligatorio (parametro 2)" >&2 + return 1 + fi + + if [[ -z "$basic_auth_pass" ]]; then + echo "setup_registry_api: basic_auth_pass es obligatorio (parametro 4)" >&2 + return 1 + fi + + local start_ts + start_ts=$(date +%s) + + # 1. Verificar conectividad SSH + echo "==> [1/7] Verificando conectividad SSH a '$ssh_host'..." >&2 + if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$ssh_host" true 2>/dev/null; then + echo "setup_registry_api: no se puede conectar a '$ssh_host' via SSH" >&2 + return 1 + fi + echo " OK: SSH conectado." >&2 + + # 2. Generar hash bcrypt para basicAuth de Traefik + echo "==> [2/7] Generando hash bcrypt para basicAuth..." >&2 + if ! command -v htpasswd &>/dev/null; then + echo "setup_registry_api: 'htpasswd' no encontrado. Instalar con: apt install apache2-utils" >&2 + return 1 + fi + local traefik_hash + traefik_hash=$(htpasswd -nbB "$basic_auth_user" "$basic_auth_pass" 2>/dev/null) + if [[ -z "$traefik_hash" ]]; then + echo "setup_registry_api: htpasswd no generó un hash válido" >&2 + return 1 + fi + # For Traefik file provider, use single $ (NOT $$ — that's only for Docker labels) + echo " OK: hash generado para usuario '$basic_auth_user'." >&2 + + # 3. Subir el repo completo al VPS via rsync (el Dockerfile necesita el contexto completo) + local remote_build_dir="/opt/fn-registry-build" + echo "==> [3/7] Sincronizando repo a '$ssh_host:$remote_build_dir' via rsync..." >&2 + rsync_deploy "$REGISTRY_ROOT/" "$ssh_host" "$remote_build_dir" >/dev/null || { + echo "setup_registry_api: rsync falló" >&2 + return 1 + } + echo " OK: repo sincronizado." >&2 + + # 4. Subir traefik-dynamic.yml con el hash real a la ruta de Coolify + local traefik_dynamic_path="/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml" + echo "==> [4/7] Generando y subiendo traefik-dynamic.yml a '$ssh_host:$traefik_dynamic_path'..." >&2 + + # Leer el template local y sustituir el placeholder + local traefik_template + traefik_template=$(< "$REGISTRY_ROOT/apps/registry_api/traefik-dynamic.yml") + # Reemplazar la línea del usuario placeholder con el hash real + local traefik_rendered + traefik_rendered=$(echo "$traefik_template" | sed "s|.*PLACEHOLDER_BASICAUTH_LINE.*| - \"${traefik_hash}\"|g") + + # Crear directorio si no existe y subir + ssh "$ssh_host" "sudo mkdir -p /data/coolify/proxy/dynamic/" >&2 + echo "$traefik_rendered" | ssh "$ssh_host" \ + "sudo tee '$traefik_dynamic_path' > /dev/null" + echo " OK: traefik-dynamic.yml desplegado en '$traefik_dynamic_path'." >&2 + + # 5. Crear .env en el VPS con el token de la API + local remote_app_dir="$remote_build_dir/apps/registry_api" + echo "==> [5/7] Creando .env en '$ssh_host:$remote_app_dir'..." >&2 + ssh "$ssh_host" "cat > '$remote_app_dir/.env'" <&2 + + # 6. Verificar que la red coolify existe; si no, crearla + echo "==> [6/7] Verificando red Docker 'coolify' y levantando el stack..." >&2 + ssh "$ssh_host" bash <<'REMOTE' + set -euo pipefail + if ! docker network ls --format '{{.Name}}' | grep -q '^coolify$'; then + echo " Creando red Docker 'coolify'..." + docker network create coolify + fi + echo " Red 'coolify' disponible." +REMOTE + + # docker compose build && up desde el directorio de la app (contexto es ../../ = remote_build_dir) + ssh "$ssh_host" bash <&2 + + # 7. Health check + local health_url="https://registry.organic-machine.com/api/status" + echo "==> [7/7] Esperando health check en '$health_url'..." >&2 + local attempts=0 + local max_attempts=12 + local status_code="" + while [[ $attempts -lt $max_attempts ]]; do + status_code=$(curl -sk -o /dev/null -w "%{http_code}" \ + -u "${basic_auth_user}:${basic_auth_pass}" \ + "$health_url" 2>/dev/null || echo "000") + if [[ "$status_code" == "200" ]]; then + break + fi + attempts=$((attempts + 1)) + echo " Intento $attempts/$max_attempts — HTTP $status_code, esperando 10s..." >&2 + sleep 10 + done + + local end_ts + end_ts=$(date +%s) + local duration_ms=$(( (end_ts - start_ts) * 1000 )) + + if [[ "$status_code" != "200" ]]; then + printf '{"status":"error","url":"%s","http_code":"%s","duration_ms":%d,"msg":"health check timeout tras %d intentos"}\n' \ + "$health_url" "$status_code" "$duration_ms" "$max_attempts" + return 1 + fi + + echo " OK: servicio respondiendo HTTP 200." >&2 + printf '{"status":"ok","url":"%s","http_code":"%s","duration_ms":%d,"ssh_host":"%s","remote_dir":"%s"}\n' \ + "$health_url" "$status_code" "$duration_ms" "$ssh_host" "$remote_app_dir" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + # Uso: setup_registry_api.sh [ssh_host] [api_token] [basic_auth_user] [basic_auth_pass] + # Variables de entorno alternativas: SSH_HOST, REGISTRY_API_TOKEN, BASIC_AUTH_USER, BASIC_AUTH_PASS + setup_registry_api \ + "${1:-${SSH_HOST:-organic-machine.com}}" \ + "${2:-${REGISTRY_API_TOKEN:-}}" \ + "${3:-${BASIC_AUTH_USER:-lucas}}" \ + "${4:-${BASIC_AUTH_PASS:-}}" +fi diff --git a/cmd/fn/main.go b/cmd/fn/main.go index 84a2db7d..4b6743fe 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -43,6 +43,8 @@ func main() { cmdApp(os.Args[2:]) case "analysis": cmdAnalysis(os.Args[2:]) + case "sync": + cmdSync(os.Args[2:]) case "help", "-h", "--help": printUsage() default: @@ -67,7 +69,8 @@ Usage: fn proposal Gestiona proposals fn project Gestiona proyectos fn app Gestiona apps externas (Gitea) - fn analysis Gestiona analyses externas (Gitea)`) + fn analysis Gestiona analyses externas (Gitea) + fn sync [status|locations] Sincroniza con servidor central`) } func root() string { diff --git a/cmd/fn/sync.go b/cmd/fn/sync.go new file mode 100644 index 00000000..0432f149 --- /dev/null +++ b/cmd/fn/sync.go @@ -0,0 +1,420 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "fn-registry/registry" +) + +const ( + defaultAPIURL = "http://localhost:8420" + pcIDFile = ".fn_pc" +) + +// syncRequest mirrors the server's SyncRequest. +type syncRequest struct { + PcID string `json:"pc_id"` + Apps []registry.App `json:"apps"` + Analysis []registry.Analysis `json:"analysis"` + Projects []registry.Project `json:"projects"` + Vaults []registry.Vault `json:"vaults"` + Proposals []registry.Proposal `json:"proposals"` + Locations []registry.PcLocation `json:"locations"` +} + +// syncResponse mirrors the server's SyncResponse. +type syncResponse struct { + Apps []registry.App `json:"apps"` + Analysis []registry.Analysis `json:"analysis"` + Projects []registry.Project `json:"projects"` + Vaults []registry.Vault `json:"vaults"` + Proposals []registry.Proposal `json:"proposals"` + Locations []registry.PcLocation `json:"locations"` + Stats struct { + Received int `json:"received"` + Updated int `json:"updated"` + Sent int `json:"sent"` + } `json:"stats"` +} + +func cmdSync(args []string) { + if len(args) > 0 { + switch args[0] { + case "status": + syncStatus() + return + case "locations": + syncLocations() + return + case "push": + syncPushPull() + return + case "help", "-h": + printSyncUsage() + return + } + } + + // Default: full sync + syncPushPull() +} + +func printSyncUsage() { + fmt.Println(`fn sync — sincroniza registry.db con el servidor central + +Usage: + fn sync Push + pull (sync completo) + fn sync status Muestra estado del PC actual + fn sync locations Mapa de ubicaciones en todos los PCs + +Config: + ~/.fn_pc Alias del PC (una linea, ej: "home-wsl") + FN_REGISTRY_API URL del servidor (default: http://localhost:8420) + REGISTRY_API_TOKEN Token de autenticacion (opcional)`) +} + +func syncPushPull() { + pcID := readPcID() + if pcID == "" { + fmt.Fprintln(os.Stderr, "error: ~/.fn_pc not found. Create it with: echo \"my-pc\" > ~/.fn_pc") + os.Exit(1) + } + + db := openDB() + defer db.Close() + + apiBase, _, _ := parseAPIURL() + fmt.Printf("syncing as %q against %s...\n", pcID, apiBase) + + // 1. Collect local data + apps, _ := db.AllApps() + analysis, _ := db.AllAnalysis() + projects, _ := db.ListAllProjects() + vaults, _ := db.AllVaults() + proposals, _ := db.AllProposals() + + // 2. Scan local directories and build pc_locations + locations := buildLocations(pcID, apps, analysis, projects, vaults) + + // 3. Send to server + req := syncRequest{ + PcID: pcID, + Apps: apps, + Analysis: analysis, + Projects: projects, + Vaults: vaults, + Proposals: proposals, + Locations: locations, + } + + body, err := json.Marshal(req) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + apiBase, basicUser, basicPass := parseAPIURL() + httpReq, err := http.NewRequest("POST", apiBase+"/api/sync", bytes.NewReader(body)) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + httpReq.Header.Set("Content-Type", "application/json") + + // BasicAuth from URL (https://user:pass@host) + if basicUser != "" { + httpReq.SetBasicAuth(basicUser, basicPass) + } + + // App-level token + token := os.Getenv("REGISTRY_API_TOKEN") + if token != "" { + httpReq.Header.Set("X-Registry-Token", token) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + fmt.Fprintf(os.Stderr, "error: cannot reach server: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "error: server returned %d\n", resp.StatusCode) + os.Exit(1) + } + + var syncResp syncResponse + if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { + fmt.Fprintf(os.Stderr, "error decoding response: %v\n", err) + os.Exit(1) + } + + // 4. Apply server data locally + imported := applySync(db, syncResp) + + fmt.Printf("done. sent %d items, server updated %d, received %d, imported %d locally\n", + syncResp.Stats.Received, syncResp.Stats.Updated, syncResp.Stats.Sent, imported) +} + +// applySync writes server data into local registry.db (newer wins). +func applySync(db *registry.DB, resp syncResponse) int { + imported := 0 + + for _, a := range resp.Apps { + existing, err := db.GetApp(a.ID) + if err != nil || a.UpdatedAt.After(existing.UpdatedAt) { + db.InsertApp(&a) + imported++ + } + } + + for _, a := range resp.Analysis { + existing, err := db.GetAnalysis(a.ID) + if err != nil || a.UpdatedAt.After(existing.UpdatedAt) { + db.InsertAnalysis(&a) + imported++ + } + } + + for _, p := range resp.Projects { + existing, err := db.GetProject(p.ID) + if err != nil || p.UpdatedAt.After(existing.UpdatedAt) { + db.InsertProject(&p) + imported++ + } + } + + for _, v := range resp.Vaults { + existing, err := db.GetVault(v.ID) + if err != nil || v.UpdatedAt.After(existing.UpdatedAt) { + db.InsertVault(&v) + imported++ + } + } + + for _, p := range resp.Proposals { + existing, err := db.GetProposal(p.ID) + if err != nil || p.UpdatedAt.After(existing.UpdatedAt) { + db.InsertProposal(&p) + imported++ + } + } + + // Locations: import all (server is authoritative) + for _, loc := range resp.Locations { + db.InsertPcLocation(&loc) + imported++ + } + + return imported +} + +// buildLocations scans local filesystem to detect which entities exist on this PC. +func buildLocations(pcID string, apps []registry.App, analysis []registry.Analysis, projects []registry.Project, vaults []registry.Vault) []registry.PcLocation { + r := root() + now := time.Now().UTC() + var locs []registry.PcLocation + + for _, a := range apps { + dirPath := a.DirPath + if dirPath == "" { + continue + } + absPath := filepath.Join(r, dirPath) + status := "active" + if _, err := os.Stat(absPath); err != nil { + status = "missing" + } + locs = append(locs, registry.PcLocation{ + EntityType: "app", + EntityID: a.ID, + PcID: pcID, + DirPath: absPath, + Status: status, + CreatedAt: now, + UpdatedAt: now, + }) + } + + for _, a := range analysis { + dirPath := a.DirPath + if dirPath == "" { + continue + } + absPath := filepath.Join(r, dirPath) + status := "active" + if _, err := os.Stat(absPath); err != nil { + status = "missing" + } + locs = append(locs, registry.PcLocation{ + EntityType: "analysis", + EntityID: a.ID, + PcID: pcID, + DirPath: absPath, + Status: status, + CreatedAt: now, + UpdatedAt: now, + }) + } + + for _, p := range projects { + dirPath := p.DirPath + if dirPath == "" { + continue + } + absPath := filepath.Join(r, dirPath) + status := "active" + if _, err := os.Stat(absPath); err != nil { + status = "missing" + } + locs = append(locs, registry.PcLocation{ + EntityType: "project", + EntityID: p.ID, + PcID: pcID, + DirPath: absPath, + Status: status, + CreatedAt: now, + UpdatedAt: now, + }) + } + + for _, v := range vaults { + path := v.Path + if path == "" { + continue + } + status := "active" + if _, err := os.Stat(path); err != nil { + status = "missing" + } + locs = append(locs, registry.PcLocation{ + EntityType: "vault", + EntityID: v.ID, + PcID: pcID, + DirPath: path, + Status: status, + CreatedAt: now, + UpdatedAt: now, + }) + } + + return locs +} + +func syncStatus() { + pcID := readPcID() + if pcID == "" { + fmt.Println("PC: (not configured — create ~/.fn_pc)") + } else { + fmt.Printf("PC: %s\n", pcID) + } + base, _, _ := parseAPIURL() + fmt.Printf("API: %s\n", base) + + db := openDB() + defer db.Close() + + apps, _ := db.AllApps() + analysis, _ := db.AllAnalysis() + projects, _ := db.ListAllProjects() + vaults, _ := db.AllVaults() + proposals, _ := db.AllProposals() + locs, _ := db.ListAllPcLocations() + + fmt.Printf("\nLocal registry:\n") + fmt.Printf(" apps: %d\n", len(apps)) + fmt.Printf(" analysis: %d\n", len(analysis)) + fmt.Printf(" projects: %d\n", len(projects)) + fmt.Printf(" vaults: %d\n", len(vaults)) + fmt.Printf(" proposals: %d\n", len(proposals)) + fmt.Printf(" locations: %d\n", len(locs)) + + // Count by PC + pcs := map[string]int{} + for _, l := range locs { + pcs[l.PcID]++ + } + if len(pcs) > 0 { + fmt.Printf("\nKnown PCs:\n") + for pc, count := range pcs { + marker := "" + if pc == pcID { + marker = " ← this" + } + fmt.Printf(" %-20s %d locations%s\n", pc, count, marker) + } + } +} + +func syncLocations() { + db := openDB() + defer db.Close() + + locs, _ := db.ListAllPcLocations() + if len(locs) == 0 { + fmt.Println("no locations registered. run 'fn sync' first.") + return + } + + pcID := readPcID() + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "PC\tTYPE\tENTITY\tPATH\tSTATUS\n") + for _, l := range locs { + marker := "" + if l.PcID == pcID { + marker = "*" + } + fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\t%s\n", l.PcID, marker, l.EntityType, l.EntityID, l.DirPath, l.Status) + } + w.Flush() +} + +// --- helpers --- + +func readPcID() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, pcIDFile)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func apiURL() string { + if u := os.Getenv("FN_REGISTRY_API"); u != "" { + return strings.TrimRight(u, "/") + } + return defaultAPIURL +} + +// parseAPIURL extracts base URL and optional basicAuth credentials from FN_REGISTRY_API. +// Supports: https://user:pass@host:port +func parseAPIURL() (base, user, pass string) { + raw := apiURL() + u, err := url.Parse(raw) + if err != nil { + return raw, "", "" + } + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + u.User = nil + return strings.TrimRight(u.String(), "/"), user, pass + } + return raw, "", "" +} diff --git a/registry/migrations/011_pc_locations.sql b/registry/migrations/011_pc_locations.sql new file mode 100644 index 00000000..922b25af --- /dev/null +++ b/registry/migrations/011_pc_locations.sql @@ -0,0 +1,18 @@ +-- pc_locations: mapa de ubicaciones por máquina. +-- Cada PC registra dónde tiene cada app, analysis, project o vault. + +CREATE TABLE IF NOT EXISTS pc_locations ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL CHECK(entity_type IN ('app', 'analysis', 'project', 'vault')), + entity_id TEXT NOT NULL, + pc_id TEXT NOT NULL, + dir_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'missing', 'archived')), + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(entity_type, entity_id, pc_id) +); + +CREATE INDEX IF NOT EXISTS idx_pc_locations_pc ON pc_locations(pc_id); +CREATE INDEX IF NOT EXISTS idx_pc_locations_entity ON pc_locations(entity_type, entity_id); diff --git a/registry/models.go b/registry/models.go index 165eb76e..3c38f9b0 100644 --- a/registry/models.go +++ b/registry/models.go @@ -224,6 +224,19 @@ type Vault struct { UpdatedAt time.Time `json:"updated_at"` } +// PcLocation maps an entity to a directory path on a specific PC. +type PcLocation struct { + ID string `json:"id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + PcID string `json:"pc_id"` + DirPath string `json:"dir_path"` + Status string `json:"status"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // GenerateID builds the canonical ID: {name}_{lang}_{domain} func GenerateID(name, lang, domain string) string { return name + "_" + lang + "_" + domain diff --git a/registry/store.go b/registry/store.go index 9bd7d3d5..048264c6 100644 --- a/registry/store.go +++ b/registry/store.go @@ -1094,3 +1094,116 @@ func scanProposals(rows interface{ Next() bool; Scan(...any) error }) ([]Proposa } return result, nil } + +// --- PcLocation CRUD --- + +// InsertPcLocation inserts or replaces a pc_location entry. +func (db *DB) InsertPcLocation(loc *PcLocation) error { + now := time.Now().UTC() + if loc.CreatedAt.IsZero() { + loc.CreatedAt = now + } + if loc.UpdatedAt.IsZero() { + loc.UpdatedAt = now + } + if loc.ID == "" { + loc.ID = loc.EntityType + "_" + loc.EntityID + "_" + loc.PcID + } + if loc.Status == "" { + loc.Status = "active" + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO pc_locations ( + id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + loc.ID, loc.EntityType, loc.EntityID, loc.PcID, loc.DirPath, loc.Status, loc.Notes, + loc.CreatedAt.Format(time.RFC3339), loc.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetPcLocationsByPC returns all locations for a given PC. +func (db *DB) GetPcLocationsByPC(pcID string) ([]PcLocation, error) { + rows, err := db.conn.Query( + "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE pc_id = ? ORDER BY entity_type, entity_id", + pcID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPcLocations(rows) +} + +// GetPcLocationsByEntity returns all PC locations for a given entity. +func (db *DB) GetPcLocationsByEntity(entityType, entityID string) ([]PcLocation, error) { + rows, err := db.conn.Query( + "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations WHERE entity_type = ? AND entity_id = ? ORDER BY pc_id", + entityType, entityID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPcLocations(rows) +} + +// ListAllPcLocations returns all pc_location entries. +func (db *DB) ListAllPcLocations() ([]PcLocation, error) { + rows, err := db.conn.Query( + "SELECT id, entity_type, entity_id, pc_id, dir_path, status, notes, created_at, updated_at FROM pc_locations ORDER BY pc_id, entity_type, entity_id", + ) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPcLocations(rows) +} + +// DeletePcLocationsByPC removes all locations for a given PC. +func (db *DB) DeletePcLocationsByPC(pcID string) error { + _, err := db.conn.Exec("DELETE FROM pc_locations WHERE pc_id = ?", pcID) + return err +} + +func scanPcLocations(rows interface{ Next() bool; Scan(...any) error }) ([]PcLocation, error) { + var result []PcLocation + for rows.Next() { + var loc PcLocation + var createdAt, updatedAt string + err := rows.Scan( + &loc.ID, &loc.EntityType, &loc.EntityID, &loc.PcID, + &loc.DirPath, &loc.Status, &loc.Notes, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning pc_location: %w", err) + } + loc.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + loc.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + result = append(result, loc) + } + return result, nil +} + +// --- Sync helpers --- + +// AllApps returns all apps (for sync export). +func (db *DB) AllApps() ([]App, error) { + return db.SearchApps("", "", "") +} + +// AllAnalysis returns all analysis entries (for sync export). +func (db *DB) AllAnalysis() ([]Analysis, error) { + return db.SearchAnalysis("", "", "") +} + +// AllProposals returns all proposals (for sync export). +func (db *DB) AllProposals() ([]Proposal, error) { + return db.ListProposals("", "") +} + +// AllVaults returns all vaults (for sync export). +func (db *DB) AllVaults() ([]Vault, error) { + return db.SearchVaults("", "") +}