2a3d780347
Adds `fn doctor` read-only diagnostic command with subcommands artefacts, services, sync, uses-functions, unused, and --json flag for agents. Each subcommand wraps a registry function in functions/infra/. New functions: - artefact_doctor, services_status, pc_locations_drift, audit_uses_functions, find_unused_functions (Go diagnostics) - backup_sqlite_db, rotate_backups, wait_for_http, wait_for_port, port_kill, tail_journal, pre_commit_hook_install (bash utilities) - notify_telegram (Go HTTP) - backup_all pipeline (tag launcher) Plus prior session leftovers (scan_secrets_in_dirty, append_diary_entry, git utilities, http_session_cookie_middleware, compile/full-git pipelines). Fixes pc_locations_drift filepath.Join bug with absolute dir_path. Documents fn doctor in CLAUDE.md, .claude/rules/fn_doctor.md (rule 23), docs/architecture.md, CHANGELOG.md (2026-05-07), and diary entry. First fn doctor uses-functions run found drift in 7/12 apps (deuda para sincronizar app.md con imports reales). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
5.8 KiB
Go
254 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
func cmdDoctor(args []string) {
|
|
jsonOut := false
|
|
sub := ""
|
|
for _, a := range args {
|
|
switch a {
|
|
case "--json":
|
|
jsonOut = true
|
|
case "-h", "--help":
|
|
doctorUsage()
|
|
return
|
|
default:
|
|
if sub == "" {
|
|
sub = a
|
|
}
|
|
}
|
|
}
|
|
|
|
r := root()
|
|
|
|
switch sub {
|
|
case "", "all":
|
|
doctorAll(r, jsonOut)
|
|
case "artefacts":
|
|
doctorArtefacts(r, jsonOut)
|
|
case "services":
|
|
doctorServices(r, jsonOut)
|
|
case "sync":
|
|
doctorSync(r, jsonOut)
|
|
case "uses-functions":
|
|
doctorUsesFunctions(r, jsonOut)
|
|
case "unused":
|
|
doctorUnused(r, jsonOut)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
|
|
doctorUsage()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func doctorUsage() {
|
|
fmt.Println(`fn doctor — diagnostico read-only del registry y artefactos
|
|
|
|
Usage:
|
|
fn doctor [subcommand] [--json]
|
|
|
|
Subcommands:
|
|
(none)|all Corre todos los checks
|
|
artefacts Salud de apps y analyses (git, venv, app.md, upstream)
|
|
services Estado de apps con tag 'service' (systemd + puerto)
|
|
sync Drift entre pc_locations BD y disco
|
|
uses-functions Audit imports reales vs uses_functions del app.md
|
|
unused Funciones del registry sin consumidores
|
|
|
|
Flags:
|
|
--json Salida JSON (para scripting/agentes)`)
|
|
}
|
|
|
|
func doctorAll(root string, jsonOut bool) {
|
|
if jsonOut {
|
|
all := map[string]any{}
|
|
if v, err := infra.ArtefactDoctor(root); err == nil {
|
|
all["artefacts"] = v
|
|
} else {
|
|
all["artefacts_error"] = err.Error()
|
|
}
|
|
if v, err := infra.ServicesStatus(root); err == nil {
|
|
all["services"] = v
|
|
} else {
|
|
all["services_error"] = err.Error()
|
|
}
|
|
if v, err := infra.PcLocationsDrift(root, ""); err == nil {
|
|
all["sync"] = v
|
|
} else {
|
|
all["sync_error"] = err.Error()
|
|
}
|
|
if v, err := infra.AuditUsesFunctions(root); err == nil {
|
|
all["uses_functions"] = v
|
|
} else {
|
|
all["uses_functions_error"] = err.Error()
|
|
}
|
|
if v, err := infra.FindUnusedFunctions(root); err == nil {
|
|
all["unused"] = v
|
|
} else {
|
|
all["unused_error"] = err.Error()
|
|
}
|
|
emit(all)
|
|
return
|
|
}
|
|
|
|
fmt.Println("=== Artefacts ===")
|
|
doctorArtefacts(root, false)
|
|
fmt.Println("\n=== Services ===")
|
|
doctorServices(root, false)
|
|
fmt.Println("\n=== Sync (pc_locations drift) ===")
|
|
doctorSync(root, false)
|
|
fmt.Println("\n=== uses_functions audit ===")
|
|
doctorUsesFunctions(root, false)
|
|
fmt.Println("\n=== Unused functions ===")
|
|
doctorUnused(root, false)
|
|
}
|
|
|
|
func doctorArtefacts(root string, jsonOut bool) {
|
|
checks, err := infra.ArtefactDoctor(root)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if jsonOut {
|
|
emit(checks)
|
|
return
|
|
}
|
|
bad := 0
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "STATUS\tTYPE\tID\tISSUES")
|
|
for _, c := range checks {
|
|
status := "OK"
|
|
issues := "-"
|
|
if !c.OK {
|
|
status = "FAIL"
|
|
issues = strings.Join(c.Issues, "; ")
|
|
bad++
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, c.Type, c.ID, issues)
|
|
}
|
|
w.Flush()
|
|
fmt.Printf("\n%d/%d artefacts healthy.\n", len(checks)-bad, len(checks))
|
|
}
|
|
|
|
func doctorServices(root string, jsonOut bool) {
|
|
statuses, err := infra.ServicesStatus(root)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if jsonOut {
|
|
emit(statuses)
|
|
return
|
|
}
|
|
if len(statuses) == 0 {
|
|
fmt.Println("No services registered (no apps with tag 'service').")
|
|
return
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "NAME\tUNIT\tACTIVE\tPORT\tLISTENING")
|
|
for _, s := range statuses {
|
|
port := "-"
|
|
listen := "-"
|
|
if s.Port > 0 {
|
|
port = fmt.Sprintf("%d", s.Port)
|
|
listen = fmt.Sprintf("%v", s.PortListening)
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, s.UnitName, s.UnitActive, port, listen)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func doctorSync(root string, jsonOut bool) {
|
|
drifts, err := infra.PcLocationsDrift(root, "")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if jsonOut {
|
|
emit(drifts)
|
|
return
|
|
}
|
|
if len(drifts) == 0 {
|
|
fmt.Println("No drift detected: pc_locations matches disk.")
|
|
return
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "TYPE\tID\tDIR\tSTATUS\tISSUE")
|
|
for _, d := range drifts {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.EntityType, d.EntityID, d.DirPath, d.Status, d.Issue)
|
|
}
|
|
w.Flush()
|
|
fmt.Printf("\n%d drift(s) detected.\n", len(drifts))
|
|
}
|
|
|
|
func doctorUsesFunctions(root string, jsonOut bool) {
|
|
audits, err := infra.AuditUsesFunctions(root)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if jsonOut {
|
|
emit(audits)
|
|
return
|
|
}
|
|
bad := 0
|
|
for _, a := range audits {
|
|
if len(a.Missing) == 0 && len(a.Unused) == 0 {
|
|
continue
|
|
}
|
|
bad++
|
|
fmt.Printf("\n%s (%s)\n", a.AppID, a.Lang)
|
|
if len(a.Missing) > 0 {
|
|
fmt.Printf(" missing in app.md: %s\n", strings.Join(a.Missing, ", "))
|
|
}
|
|
if len(a.Unused) > 0 {
|
|
fmt.Printf(" declared but unused: %s\n", strings.Join(a.Unused, ", "))
|
|
}
|
|
}
|
|
if bad == 0 {
|
|
fmt.Println("All apps have matching uses_functions vs imports.")
|
|
} else {
|
|
fmt.Printf("\n%d/%d apps have drift.\n", bad, len(audits))
|
|
}
|
|
}
|
|
|
|
func doctorUnused(root string, jsonOut bool) {
|
|
unused, err := infra.FindUnusedFunctions(root)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if jsonOut {
|
|
emit(unused)
|
|
return
|
|
}
|
|
if len(unused) == 0 {
|
|
fmt.Println("No unused functions.")
|
|
return
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tAGE_DAYS")
|
|
for _, u := range unused {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", u.ID, u.Lang, u.Domain, u.AgeDays)
|
|
}
|
|
w.Flush()
|
|
fmt.Printf("\n%d unused functions (candidates to remove).\n", len(unused))
|
|
}
|
|
|
|
func emit(v any) {
|
|
b, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "json error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(string(b))
|
|
}
|