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"` Modules []registry.Module `json:"modules"` 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"` Modules []registry.Module `json:"modules"` 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() modules, _ := db.AllModules() 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, Modules: modules, 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 _, m := range resp.Modules { existing, err := db.GetModule(m.ID) if err != nil || m.UpdatedAt.After(existing.UpdatedAt) { db.InsertModule(&m) 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() modules, _ := db.AllModules() 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(" modules: %d\n", len(modules)) 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, "", "" }