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,185 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// LocationDrift describes a discrepancy between pc_locations and the real disk state.
|
||||
type LocationDrift struct {
|
||||
EntityType string // app, analysis, project, vault
|
||||
EntityID string // id of the artefact
|
||||
DirPath string // dir_path registered or detected
|
||||
Status string // value in pc_locations (active/missing/archived) or "" if not registered
|
||||
Issue string // "missing_on_disk" | "untracked_on_disk" | "status_should_be_active"
|
||||
}
|
||||
|
||||
// PcLocationsDrift compares pc_locations entries against real disk state for pcID.
|
||||
// If pcID is empty it is read from the first non-empty line of ~/.fn_pc.
|
||||
// Returns a slice of drift items (never nil, may be empty).
|
||||
func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error) {
|
||||
if pcID == "" {
|
||||
id, err := readFnPC()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: cannot determine pcID: %w", err)
|
||||
}
|
||||
pcID = id
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(registryRoot, "registry.db")
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: open registry.db: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query A: registered locations for this PC
|
||||
rows, err := db.Query(
|
||||
`SELECT entity_type, entity_id, dir_path, status FROM pc_locations WHERE pc_id = ?`,
|
||||
pcID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: query pc_locations: %w", err)
|
||||
}
|
||||
|
||||
type locRow struct {
|
||||
entityType string
|
||||
entityID string
|
||||
dirPath string
|
||||
status string
|
||||
}
|
||||
|
||||
var registered []locRow
|
||||
registeredKey := map[string]locRow{} // key: entityType+"/"+entityID
|
||||
|
||||
for rows.Next() {
|
||||
var r locRow
|
||||
if err := rows.Scan(&r.entityType, &r.entityID, &r.dirPath, &r.status); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("pc_locations_drift: scan: %w", err)
|
||||
}
|
||||
registered = append(registered, r)
|
||||
registeredKey[r.entityType+"/"+r.entityID] = r
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("pc_locations_drift: rows: %w", err)
|
||||
}
|
||||
|
||||
drifts := []LocationDrift{}
|
||||
|
||||
// Check registered entries against disk
|
||||
for _, r := range registered {
|
||||
fullPath := r.dirPath
|
||||
if !filepath.IsAbs(fullPath) {
|
||||
fullPath = filepath.Join(registryRoot, fullPath)
|
||||
}
|
||||
exists := dirExists(fullPath)
|
||||
|
||||
if r.status == "active" && !exists {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: r.entityType,
|
||||
EntityID: r.entityID,
|
||||
DirPath: r.dirPath,
|
||||
Status: r.status,
|
||||
Issue: "missing_on_disk",
|
||||
})
|
||||
} else if r.status == "missing" && exists {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: r.entityType,
|
||||
EntityID: r.entityID,
|
||||
DirPath: r.dirPath,
|
||||
Status: r.status,
|
||||
Issue: "status_should_be_active",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Query B: all indexed artefacts (apps + analysis) with dir_path
|
||||
type artefact struct {
|
||||
entityType string
|
||||
id string
|
||||
dirPath string
|
||||
}
|
||||
var artefacts []artefact
|
||||
|
||||
for _, q := range []struct {
|
||||
table string
|
||||
entityType string
|
||||
}{
|
||||
{"apps", "app"},
|
||||
{"analysis", "analysis"},
|
||||
} {
|
||||
arows, err := db.Query(fmt.Sprintf(`SELECT id, dir_path FROM %s WHERE dir_path != ''`, q.table))
|
||||
if err != nil {
|
||||
// Table may not exist in all registry versions; skip gracefully
|
||||
continue
|
||||
}
|
||||
for arows.Next() {
|
||||
var a artefact
|
||||
a.entityType = q.entityType
|
||||
if err := arows.Scan(&a.id, &a.dirPath); err != nil {
|
||||
arows.Close()
|
||||
return nil, fmt.Errorf("pc_locations_drift: scan %s: %w", q.table, err)
|
||||
}
|
||||
artefacts = append(artefacts, a)
|
||||
}
|
||||
arows.Close()
|
||||
}
|
||||
|
||||
// Cross: indexed artefact on disk but not in pc_locations for pcID
|
||||
for _, a := range artefacts {
|
||||
fullPath := a.dirPath
|
||||
if !filepath.IsAbs(fullPath) {
|
||||
fullPath = filepath.Join(registryRoot, fullPath)
|
||||
}
|
||||
if !dirExists(fullPath) {
|
||||
continue // not on this machine, that's fine
|
||||
}
|
||||
key := a.entityType + "/" + a.id
|
||||
if _, found := registeredKey[key]; !found {
|
||||
drifts = append(drifts, LocationDrift{
|
||||
EntityType: a.entityType,
|
||||
EntityID: a.id,
|
||||
DirPath: a.dirPath,
|
||||
Status: "",
|
||||
Issue: "untracked_on_disk",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return drifts, nil
|
||||
}
|
||||
|
||||
// readFnPC reads the first non-empty, non-comment line from ~/.fn_pc.
|
||||
func readFnPC() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(filepath.Join(home, ".fn_pc"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("~/.fn_pc not found: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("~/.fn_pc is empty")
|
||||
}
|
||||
|
||||
// dirExists returns true if path is an existing directory.
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
Reference in New Issue
Block a user