feat(doctor): add fn doctor CLI + 14 functions for system management
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>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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))
|
||||
}
|
||||
+5
-1
@@ -45,6 +45,8 @@ func main() {
|
||||
cmdAnalysis(os.Args[2:])
|
||||
case "sync":
|
||||
cmdSync(os.Args[2:])
|
||||
case "doctor":
|
||||
cmdDoctor(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
@@ -70,7 +72,9 @@ Usage:
|
||||
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 sync [status|locations] Sincroniza con servidor central`)
|
||||
fn sync [status|locations] Sincroniza con servidor central
|
||||
fn doctor [artefacts|services|sync|uses-functions|unused] [--json]
|
||||
Diagnostico read-only del registry`)
|
||||
}
|
||||
|
||||
func root() string {
|
||||
|
||||
Reference in New Issue
Block a user