feat: registry_api + fn sync — sincronización de registry.db entre PCs
Nuevo sistema para mantener datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) sincronizados entre múltiples máquinas via una API HTTP central desplegada en organic-machine.com. - Migración 011: tabla pc_locations (mapa de ubicaciones por PC) - registry/models.go: struct PcLocation - registry/store.go: CRUD PcLocation + helpers de sync - cmd/fn/sync.go: subcomando fn sync (push+pull, status, locations) - bash/functions/infra/setup_registry_api: pipeline de deploy Docker+Traefik - CLAUDE.md: documentación de sync y pc_locations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-1
@@ -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 <id>
|
||||
fn proposal update <id> --status approved [--reviewed-by lucas]
|
||||
|
||||
# Sync entre PCs
|
||||
fn sync # Push+pull completo contra el servidor
|
||||
fn sync status # Estado local: PC, API, conteos
|
||||
fn sync locations # Mapa de ubicaciones en todos los PCs
|
||||
# Config: ~/.fn_pc (identidad PC), FN_REGISTRY_API (URL), REGISTRY_API_TOKEN (token)
|
||||
# URL con basicAuth: export FN_REGISTRY_API="https://user:pass@registry.organic-machine.com"
|
||||
|
||||
# Operations (desde directorio con operations.db)
|
||||
fn ops init [path]
|
||||
fn ops entity add|list|show|delete
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: setup_registry_api
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "setup_registry_api(ssh_host: string, api_token: string, basic_auth_user: string, basic_auth_pass: string) -> json"
|
||||
description: "Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy). Sincroniza el repo via rsync, genera el hash bcrypt para basicAuth, sube el traefik-dynamic.yml, crea el .env con el token, hace docker compose build+up y verifica el health check."
|
||||
tags: [launcher, deploy, docker, traefik, registry, coolify, infra, ssh]
|
||||
uses_functions: [rsync_deploy_bash_infra, ssh_exec_go_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: ssh_host
|
||||
desc: "Alias SSH del VPS destino definido en ~/.ssh/config (default: organic-machine.com)"
|
||||
- name: api_token
|
||||
desc: "Token de autenticación para la registry_api (REGISTRY_API_TOKEN). Se escribe en el .env remoto."
|
||||
- name: basic_auth_user
|
||||
desc: "Usuario para basicAuth de Traefik (default: lucas). Se usa para generar el hash bcrypt con htpasswd."
|
||||
- name: basic_auth_pass
|
||||
desc: "Password para basicAuth de Traefik. Se hashea con bcrypt (htpasswd -nB -C 10) y se escapa a $$ para Traefik."
|
||||
output: "JSON con status (ok|error), url del servicio, http_code del health check, duration_ms, ssh_host y remote_dir. Exit code 1 si algún paso falla."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "bash/functions/infra/setup_registry_api.sh"
|
||||
---
|
||||
|
||||
## Requisitos previos
|
||||
|
||||
- `htpasswd` instalado localmente (`apt install apache2-utils`)
|
||||
- `rsync` instalado localmente
|
||||
- SSH alias `organic-machine.com` (o el host indicado) configurado en `~/.ssh/config`
|
||||
- El usuario SSH debe tener `sudo` sin password para `mkdir -p /data/coolify/proxy/dynamic/` y `tee` en esa ruta
|
||||
- Red Docker `coolify` existente en el VPS (se crea automáticamente si no existe)
|
||||
- Traefik corriendo con file watcher en `/data/coolify/proxy/dynamic/` (Coolify proxy estándar)
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Forma directa como script
|
||||
bash bash/functions/infra/setup_registry_api.sh \
|
||||
organic-machine.com \
|
||||
"mi-token-secreto" \
|
||||
lucas \
|
||||
"mi-password"
|
||||
|
||||
# Como función sourced
|
||||
source bash/functions/infra/setup_registry_api.sh
|
||||
|
||||
result=$(setup_registry_api \
|
||||
"organic-machine.com" \
|
||||
"mi-token-secreto" \
|
||||
"lucas" \
|
||||
"mi-password")
|
||||
echo "$result"
|
||||
# {"status":"ok","url":"https://registry.organic-machine.com/api/status","http_code":"200","duration_ms":45231,"ssh_host":"organic-machine.com","remote_dir":"/opt/fn-registry-build/apps/registry_api"}
|
||||
|
||||
# Via variables de entorno
|
||||
export REGISTRY_API_TOKEN="mi-token-secreto"
|
||||
export BASIC_AUTH_PASS="mi-password"
|
||||
bash bash/functions/infra/setup_registry_api.sh
|
||||
```
|
||||
|
||||
## Pasos del pipeline
|
||||
|
||||
1. **Verificar SSH** — `ssh -o BatchMode=yes -o ConnectTimeout=10` para confirmar acceso al VPS
|
||||
2. **Generar hash bcrypt** — `htpasswd -nB -C 10` localmente, escapar `$` a `$$` para Traefik
|
||||
3. **rsync del repo** — sube el repo completo a `/opt/fn-registry-build/` en el VPS (el Dockerfile necesita el contexto raíz)
|
||||
4. **Subir traefik-dynamic.yml** — reemplaza el placeholder del hash en el template local y lo sube a `/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml` via `sudo tee`
|
||||
5. **Crear .env** — escribe `REGISTRY_API_TOKEN=...` en `apps/registry_api/.env` en el VPS
|
||||
6. **docker compose build && up -d** — construye la imagen (multi-stage, CGO+FTS5) y levanta el container con la red `coolify`
|
||||
7. **Health check** — polling a `https://registry.organic-machine.com/api/status` cada 10s, máximo 12 intentos (2 minutos)
|
||||
|
||||
## Notas
|
||||
|
||||
- El `docker-compose.yml` de la app usa `context: ../../` para incluir `registry/`, `functions/`, `cmd/` y `apps/registry_api/` en el build. Por eso se sincroniza el repo completo y no solo la app.
|
||||
- El Dockerfile genera el binario `registry_api` con `CGO_ENABLED=1 -tags fts5` (SQLite + FTS5). El `registry.db` se genera en el primer arranque via `fn index` dentro del container, o puede montarse externamente via el volumen `/data`.
|
||||
- Traefik detecta el cambio en `/data/coolify/proxy/dynamic/` automáticamente (file provider con file watcher), sin necesidad de reiniciar Traefik.
|
||||
- Para re-deploys: ejecutar el mismo script — rsync es idempotente y `docker compose up -d` recrea el container si la imagen cambió.
|
||||
- Si `REGISTRY_API_TOKEN` está vacío, la API arranca sin autenticación (solo basicAuth de Traefik protege el acceso).
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup_registry_api — Deploy completo de registry_api en VPS con Docker + Traefik (Coolify proxy)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../../" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/rsync_deploy.sh"
|
||||
|
||||
setup_registry_api() {
|
||||
local ssh_host="${1:-organic-machine.com}"
|
||||
local api_token="${2:-}"
|
||||
local basic_auth_user="${3:-lucas}"
|
||||
local basic_auth_pass="${4:-}"
|
||||
|
||||
if [[ -z "$api_token" ]]; then
|
||||
echo "setup_registry_api: REGISTRY_API_TOKEN es obligatorio (parametro 2)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$basic_auth_pass" ]]; then
|
||||
echo "setup_registry_api: basic_auth_pass es obligatorio (parametro 4)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local start_ts
|
||||
start_ts=$(date +%s)
|
||||
|
||||
# 1. Verificar conectividad SSH
|
||||
echo "==> [1/7] Verificando conectividad SSH a '$ssh_host'..." >&2
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$ssh_host" true 2>/dev/null; then
|
||||
echo "setup_registry_api: no se puede conectar a '$ssh_host' via SSH" >&2
|
||||
return 1
|
||||
fi
|
||||
echo " OK: SSH conectado." >&2
|
||||
|
||||
# 2. Generar hash bcrypt para basicAuth de Traefik
|
||||
echo "==> [2/7] Generando hash bcrypt para basicAuth..." >&2
|
||||
if ! command -v htpasswd &>/dev/null; then
|
||||
echo "setup_registry_api: 'htpasswd' no encontrado. Instalar con: apt install apache2-utils" >&2
|
||||
return 1
|
||||
fi
|
||||
local traefik_hash
|
||||
traefik_hash=$(htpasswd -nbB "$basic_auth_user" "$basic_auth_pass" 2>/dev/null)
|
||||
if [[ -z "$traefik_hash" ]]; then
|
||||
echo "setup_registry_api: htpasswd no generó un hash válido" >&2
|
||||
return 1
|
||||
fi
|
||||
# For Traefik file provider, use single $ (NOT $$ — that's only for Docker labels)
|
||||
echo " OK: hash generado para usuario '$basic_auth_user'." >&2
|
||||
|
||||
# 3. Subir el repo completo al VPS via rsync (el Dockerfile necesita el contexto completo)
|
||||
local remote_build_dir="/opt/fn-registry-build"
|
||||
echo "==> [3/7] Sincronizando repo a '$ssh_host:$remote_build_dir' via rsync..." >&2
|
||||
rsync_deploy "$REGISTRY_ROOT/" "$ssh_host" "$remote_build_dir" >/dev/null || {
|
||||
echo "setup_registry_api: rsync falló" >&2
|
||||
return 1
|
||||
}
|
||||
echo " OK: repo sincronizado." >&2
|
||||
|
||||
# 4. Subir traefik-dynamic.yml con el hash real a la ruta de Coolify
|
||||
local traefik_dynamic_path="/data/coolify/proxy/dynamic/registry-api-organic-machine-com.yml"
|
||||
echo "==> [4/7] Generando y subiendo traefik-dynamic.yml a '$ssh_host:$traefik_dynamic_path'..." >&2
|
||||
|
||||
# Leer el template local y sustituir el placeholder
|
||||
local traefik_template
|
||||
traefik_template=$(< "$REGISTRY_ROOT/apps/registry_api/traefik-dynamic.yml")
|
||||
# Reemplazar la línea del usuario placeholder con el hash real
|
||||
local traefik_rendered
|
||||
traefik_rendered=$(echo "$traefik_template" | sed "s|.*PLACEHOLDER_BASICAUTH_LINE.*| - \"${traefik_hash}\"|g")
|
||||
|
||||
# Crear directorio si no existe y subir
|
||||
ssh "$ssh_host" "sudo mkdir -p /data/coolify/proxy/dynamic/" >&2
|
||||
echo "$traefik_rendered" | ssh "$ssh_host" \
|
||||
"sudo tee '$traefik_dynamic_path' > /dev/null"
|
||||
echo " OK: traefik-dynamic.yml desplegado en '$traefik_dynamic_path'." >&2
|
||||
|
||||
# 5. Crear .env en el VPS con el token de la API
|
||||
local remote_app_dir="$remote_build_dir/apps/registry_api"
|
||||
echo "==> [5/7] Creando .env en '$ssh_host:$remote_app_dir'..." >&2
|
||||
ssh "$ssh_host" "cat > '$remote_app_dir/.env'" <<EOF
|
||||
REGISTRY_API_TOKEN=${api_token}
|
||||
EOF
|
||||
echo " OK: .env creado." >&2
|
||||
|
||||
# 6. Verificar que la red coolify existe; si no, crearla
|
||||
echo "==> [6/7] Verificando red Docker 'coolify' y levantando el stack..." >&2
|
||||
ssh "$ssh_host" bash <<'REMOTE'
|
||||
set -euo pipefail
|
||||
if ! docker network ls --format '{{.Name}}' | grep -q '^coolify$'; then
|
||||
echo " Creando red Docker 'coolify'..."
|
||||
docker network create coolify
|
||||
fi
|
||||
echo " Red 'coolify' disponible."
|
||||
REMOTE
|
||||
|
||||
# docker compose build && up desde el directorio de la app (contexto es ../../ = remote_build_dir)
|
||||
ssh "$ssh_host" bash <<REMOTE
|
||||
set -euo pipefail
|
||||
cd '$remote_app_dir'
|
||||
echo " docker compose build..."
|
||||
docker compose build
|
||||
echo " docker compose up -d..."
|
||||
docker compose up -d
|
||||
echo " Contenedor levantado."
|
||||
REMOTE
|
||||
echo " OK: stack Docker levantado." >&2
|
||||
|
||||
# 7. Health check
|
||||
local health_url="https://registry.organic-machine.com/api/status"
|
||||
echo "==> [7/7] Esperando health check en '$health_url'..." >&2
|
||||
local attempts=0
|
||||
local max_attempts=12
|
||||
local status_code=""
|
||||
while [[ $attempts -lt $max_attempts ]]; do
|
||||
status_code=$(curl -sk -o /dev/null -w "%{http_code}" \
|
||||
-u "${basic_auth_user}:${basic_auth_pass}" \
|
||||
"$health_url" 2>/dev/null || echo "000")
|
||||
if [[ "$status_code" == "200" ]]; then
|
||||
break
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
echo " Intento $attempts/$max_attempts — HTTP $status_code, esperando 10s..." >&2
|
||||
sleep 10
|
||||
done
|
||||
|
||||
local end_ts
|
||||
end_ts=$(date +%s)
|
||||
local duration_ms=$(( (end_ts - start_ts) * 1000 ))
|
||||
|
||||
if [[ "$status_code" != "200" ]]; then
|
||||
printf '{"status":"error","url":"%s","http_code":"%s","duration_ms":%d,"msg":"health check timeout tras %d intentos"}\n' \
|
||||
"$health_url" "$status_code" "$duration_ms" "$max_attempts"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " OK: servicio respondiendo HTTP 200." >&2
|
||||
printf '{"status":"ok","url":"%s","http_code":"%s","duration_ms":%d,"ssh_host":"%s","remote_dir":"%s"}\n' \
|
||||
"$health_url" "$status_code" "$duration_ms" "$ssh_host" "$remote_app_dir"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
# Uso: setup_registry_api.sh [ssh_host] [api_token] [basic_auth_user] [basic_auth_pass]
|
||||
# Variables de entorno alternativas: SSH_HOST, REGISTRY_API_TOKEN, BASIC_AUTH_USER, BASIC_AUTH_PASS
|
||||
setup_registry_api \
|
||||
"${1:-${SSH_HOST:-organic-machine.com}}" \
|
||||
"${2:-${REGISTRY_API_TOKEN:-}}" \
|
||||
"${3:-${BASIC_AUTH_USER:-lucas}}" \
|
||||
"${4:-${BASIC_AUTH_PASS:-}}"
|
||||
fi
|
||||
+4
-1
@@ -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 <add|list|show|update> Gestiona proposals
|
||||
fn project <init|list|show|status> Gestiona proyectos
|
||||
fn app <list|clone|pull> Gestiona apps externas (Gitea)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)`)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)
|
||||
fn sync [status|locations] Sincroniza con servidor central`)
|
||||
}
|
||||
|
||||
func root() string {
|
||||
|
||||
+420
@@ -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, "", ""
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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("", "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user