diff --git a/cmd/fn/main.go b/cmd/fn/main.go index a3b928bf..e8415f00 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -29,6 +29,8 @@ func main() { cmdShow(os.Args[2:]) case "add": cmdAdd(os.Args[2:]) + case "ops": + cmdOps(os.Args[2:]) case "help", "-h", "--help": printUsage() default: @@ -46,7 +48,8 @@ Usage: fn search [-k kind] [-p purity] [-l lang] [-d domain] fn list [-k kind] [-d domain] [-l lang] fn show Muestra entrada completa - fn add [-k kind] Abre $EDITOR con template`) + fn add [-k kind] Abre $EDITOR con template + fn ops Gestiona operations.db (fn ops help)`) } func root() string { diff --git a/cmd/fn/ops.go b/cmd/fn/ops.go new file mode 100644 index 00000000..385edbdc --- /dev/null +++ b/cmd/fn/ops.go @@ -0,0 +1,634 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + ops "fn-registry/fn_operations" + "fn-registry/registry" +) + +const opsDBName = "operations.db" + +func cmdOps(args []string) { + if len(args) < 1 { + printOpsUsage() + os.Exit(1) + } + + switch args[0] { + case "init": + cmdOpsInit(args[1:]) + case "entity": + cmdOpsEntity(args[1:]) + case "relation": + cmdOpsRelation(args[1:]) + case "graph": + cmdOpsGraph() + case "snapshot": + cmdOpsSnapshot(args[1:]) + case "help", "-h", "--help": + printOpsUsage() + default: + fmt.Fprintf(os.Stderr, "unknown ops command: %s\n", args[0]) + printOpsUsage() + os.Exit(1) + } +} + +func printOpsUsage() { + fmt.Println(`fn ops — operations CLI + +Usage: + fn ops init [path] Crea operations.db en path (default: .) + fn ops entity add Añade entity + fn ops entity list [-d domain] [-s status] Lista entities + fn ops entity show Muestra entity + fn ops entity delete Elimina entity + fn ops relation add Añade relation + fn ops relation list [entity_id] Lista relations + fn ops relation show Muestra relation + fn ops relation delete Elimina relation + fn ops graph Grafo ASCII de entities y relations + fn ops snapshot list Lista tipos snapshotted + +Entity flags: + --id --name --type-ref --source + --domain --status --description + --tags --metadata --notes + +Relation flags: + --id --name --from --to + --via --direction --status + --purity --weight <0.0-1.0> --description + --tags --notes `) +} + +// --- ops init --- + +func cmdOpsInit(args []string) { + dir := "." + if len(args) > 0 { + dir = args[0] + } + + path := filepath.Join(dir, opsDBName) + if _, err := os.Stat(path); err == nil { + fmt.Fprintf(os.Stderr, "operations.db already exists at %s\n", path) + os.Exit(1) + } + + // Copy from template if available, otherwise create fresh + templatePath := filepath.Join(root(), "fn_operations", "project_template", "operations.db") + if _, err := os.Stat(templatePath); err == nil { + src, err := os.Open(templatePath) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening template: %v\n", err) + os.Exit(1) + } + defer src.Close() + + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "error creating directory: %v\n", err) + os.Exit(1) + } + + dst, err := os.Create(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error creating db: %v\n", err) + os.Exit(1) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + fmt.Fprintf(os.Stderr, "error copying template: %v\n", err) + os.Exit(1) + } + fmt.Printf("operations.db created at %s (from template)\n", path) + return + } + + // Create fresh + db, err := ops.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + db.Close() + fmt.Printf("operations.db created at %s\n", path) +} + +// --- ops entity --- + +func cmdOpsEntity(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops entity ...") + os.Exit(1) + } + + switch args[0] { + case "add": + cmdOpsEntityAdd(args[1:]) + case "list": + cmdOpsEntityList(args[1:]) + case "show": + cmdOpsEntityShow(args[1:]) + case "delete": + cmdOpsEntityDelete(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown entity command: %s\n", args[0]) + os.Exit(1) + } +} + +func cmdOpsEntityAdd(args []string) { + var e ops.Entity + e.Status = ops.StatusActive + var tagsStr, metadataStr string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--id": + i++; e.ID = args[i] + case "--name": + i++; e.Name = args[i] + case "--type-ref": + i++; e.TypeRef = args[i] + case "--source": + i++; e.Source = args[i] + case "--domain": + i++; e.Domain = args[i] + case "--status": + i++; e.Status = ops.EntityStatus(args[i]) + case "--description": + i++; e.Description = args[i] + case "--tags": + i++; tagsStr = args[i] + case "--metadata": + i++; metadataStr = args[i] + case "--notes": + i++; e.Notes = args[i] + } + } + + if e.Name == "" || e.TypeRef == "" || e.Source == "" { + fmt.Fprintln(os.Stderr, "required: --name, --type-ref, --source") + os.Exit(1) + } + if e.ID == "" { + e.ID = e.Name + } + + if tagsStr != "" { + e.Tags = strings.Split(tagsStr, ",") + } + if metadataStr != "" { + json.Unmarshal([]byte(metadataStr), &e.Metadata) + } + + opsDB := openOpsDB() + defer opsDB.Close() + + // Try to open registry for type snapshot + regDB := tryOpenRegistryDB() + if regDB != nil { + defer regDB.Close() + } + + if err := ops.InsertEntityWithSnapshot(opsDB, regDB, &e); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Entity %s added\n", e.ID) +} + +func cmdOpsEntityList(args []string) { + var domain string + var status ops.EntityStatus + for i := 0; i < len(args); i++ { + switch args[i] { + case "-d": + i++; domain = args[i] + case "-s": + i++; status = ops.EntityStatus(args[i]) + } + } + + db := openOpsDB() + defer db.Close() + + entities, err := db.ListEntities(domain, status) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(entities) == 0 { + fmt.Println("No entities.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tTYPE_REF\tSTATUS\tSOURCE\tDOMAIN") + for _, e := range entities { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.ID, e.TypeRef, e.Status, e.Source, e.Domain) + } + w.Flush() +} + +func cmdOpsEntityShow(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops entity show ") + os.Exit(1) + } + + db := openOpsDB() + defer db.Close() + + e, err := db.GetEntity(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if e == nil { + fmt.Fprintf(os.Stderr, "entity not found: %s\n", args[0]) + os.Exit(1) + } + + fmt.Printf("ID: %s\n", e.ID) + fmt.Printf("Name: %s\n", e.Name) + fmt.Printf("Type ref: %s\n", e.TypeRef) + fmt.Printf("Status: %s\n", e.Status) + fmt.Printf("Source: %s\n", e.Source) + fmt.Printf("Domain: %s\n", e.Domain) + fmt.Printf("Description: %s\n", e.Description) + fmt.Printf("Tags: %s\n", strings.Join(e.Tags, ", ")) + if len(e.Metadata) > 0 { + meta, _ := json.MarshalIndent(e.Metadata, " ", " ") + fmt.Printf("Metadata: %s\n", meta) + } + if e.Notes != "" { + fmt.Printf("Notes: %s\n", e.Notes) + } + fmt.Printf("Created: %s\n", e.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", e.UpdatedAt.Format("2006-01-02 15:04:05")) +} + +func cmdOpsEntityDelete(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops entity delete ") + os.Exit(1) + } + + db := openOpsDB() + defer db.Close() + + if err := db.DeleteEntity(args[0]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Entity %s deleted\n", args[0]) +} + +// --- ops relation --- + +func cmdOpsRelation(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops relation ...") + os.Exit(1) + } + + switch args[0] { + case "add": + cmdOpsRelationAdd(args[1:]) + case "list": + cmdOpsRelationList(args[1:]) + case "show": + cmdOpsRelationShow(args[1:]) + case "delete": + cmdOpsRelationDelete(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown relation command: %s\n", args[0]) + os.Exit(1) + } +} + +func cmdOpsRelationAdd(args []string) { + var r ops.Relation + r.Direction = ops.DirUnidirectional + r.Status = ops.RelDesigned + var tagsStr string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--id": + i++; r.ID = args[i] + case "--name": + i++; r.Name = args[i] + case "--from": + i++; r.FromEntity = args[i] + case "--to": + i++; r.ToEntity = args[i] + case "--via": + i++; r.Via = args[i] + case "--direction": + i++; r.Direction = ops.Direction(args[i]) + case "--status": + i++; r.Status = ops.RelationStatus(args[i]) + case "--purity": + i++; r.Purity = args[i] + case "--weight": + i++ + var w float64 + fmt.Sscanf(args[i], "%f", &w) + r.Weight = &w + case "--description": + i++; r.Description = args[i] + case "--tags": + i++; tagsStr = args[i] + case "--notes": + i++; r.Notes = args[i] + } + } + + if r.Name == "" || r.ToEntity == "" { + fmt.Fprintln(os.Stderr, "required: --name, --to (and --from for simple relations)") + os.Exit(1) + } + if r.ID == "" && r.FromEntity != "" { + via := "semantic" + if r.Via != "" { + via = r.Via + } + r.ID = fmt.Sprintf("%s__to__%s__via__%s", r.FromEntity, r.ToEntity, via) + } + if tagsStr != "" { + r.Tags = strings.Split(tagsStr, ",") + } + + db := openOpsDB() + defer db.Close() + + if err := ops.InsertRelationSafe(db, &r); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Relation %s added\n", r.ID) +} + +func cmdOpsRelationList(args []string) { + var entityID string + if len(args) > 0 { + entityID = args[0] + } + + db := openOpsDB() + defer db.Close() + + rels, err := db.ListRelations(entityID) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(rels) == 0 { + fmt.Println("No relations.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tFROM\tTO\tVIA\tDIRECTION\tSTATUS") + for _, r := range rels { + from := r.FromEntity + if from == "" { + from = "(inputs)" + } + via := r.Via + if via == "" { + via = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.ID, r.Name, from, r.ToEntity, via, r.Direction, r.Status) + } + w.Flush() +} + +func cmdOpsRelationShow(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops relation show ") + os.Exit(1) + } + + db := openOpsDB() + defer db.Close() + + r, err := db.GetRelation(args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + if r == nil { + fmt.Fprintf(os.Stderr, "relation not found: %s\n", args[0]) + os.Exit(1) + } + + fmt.Printf("ID: %s\n", r.ID) + fmt.Printf("Name: %s\n", r.Name) + fmt.Printf("From: %s\n", r.FromEntity) + fmt.Printf("To: %s\n", r.ToEntity) + fmt.Printf("Via: %s\n", r.Via) + fmt.Printf("Description: %s\n", r.Description) + fmt.Printf("Purity: %s\n", r.Purity) + fmt.Printf("Direction: %s\n", r.Direction) + if r.Weight != nil { + fmt.Printf("Weight: %.2f\n", *r.Weight) + } + fmt.Printf("Status: %s\n", r.Status) + fmt.Printf("Tags: %s\n", strings.Join(r.Tags, ", ")) + if r.Notes != "" { + fmt.Printf("Notes: %s\n", r.Notes) + } + + // Show inputs if any + inputs, _ := db.GetRelationInputs(r.ID) + if len(inputs) > 0 { + fmt.Println("\nInputs:") + for _, ri := range inputs { + ord := "" + if ri.Order != nil { + ord = fmt.Sprintf(" (order: %d)", *ri.Order) + } + fmt.Printf(" %s [%s]%s\n", ri.EntityID, ri.Role, ord) + } + } +} + +func cmdOpsRelationDelete(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: fn ops relation delete ") + os.Exit(1) + } + + db := openOpsDB() + defer db.Close() + + if err := db.DeleteRelation(args[0]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Relation %s deleted\n", args[0]) +} + +// --- ops graph --- + +func cmdOpsGraph() { + db := openOpsDB() + defer db.Close() + + g, err := ops.GetEntityGraph(db) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(g.Entities) == 0 { + fmt.Println("Empty graph.") + return + } + + fmt.Println("Entities:") + for _, e := range g.Entities { + fmt.Printf(" [%s] (%s) status:%s source:%s\n", e.ID, e.TypeRef, e.Status, e.Source) + } + + if len(g.Relations) > 0 { + fmt.Println("\nRelations:") + for _, r := range g.Relations { + via := "" + if r.Via != "" { + via = fmt.Sprintf(" via:%s", r.Via) + } + + inputs, hasInputs := g.Inputs[r.ID] + if hasInputs { + sources := make([]string, len(inputs)) + for i, ri := range inputs { + sources[i] = fmt.Sprintf("%s[%s]", ri.EntityID, ri.Role) + } + fmt.Printf(" (%s) %s → %s%s\n", + strings.Join(sources, " + "), r.Name, r.ToEntity, via) + } else { + from := r.FromEntity + if from == "" { + from = "?" + } + dir := "→" + if r.Direction == ops.DirBidirectional { + dir = "↔" + } + fmt.Printf(" %s %s %s %s%s\n", from, dir, r.Name, r.ToEntity, via) + } + } + } +} + +// --- ops snapshot --- + +func cmdOpsSnapshot(args []string) { + if len(args) < 1 || args[0] != "list" { + fmt.Fprintln(os.Stderr, "usage: fn ops snapshot list") + os.Exit(1) + } + + db := openOpsDB() + defer db.Close() + + snaps, err := db.ListTypeSnapshots() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(snaps) == 0 { + fmt.Println("No type snapshots.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tVERSION\tLANG\tALGEBRAIC\tSNAPPED_AT") + for _, s := range snaps { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + s.ID, s.Version, s.Lang, s.Algebraic, s.SnappedAt.Format("2006-01-02 15:04")) + } + w.Flush() +} + +// --- helpers --- + +func findOpsDB() string { + dir, _ := os.Getwd() + for { + path := filepath.Join(dir, opsDBName) + if _, err := os.Stat(path); err == nil { + return path + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return filepath.Join(".", opsDBName) +} + +func openOpsDB() *ops.DB { + path := findOpsDB() + db, err := ops.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening operations.db: %v\n", err) + fmt.Fprintln(os.Stderr, "Run 'fn ops init' first to create one.") + os.Exit(1) + } + return db +} + +func tryOpenRegistryDB() *registry.DB { + // Try FN_REGISTRY_ROOT env var first + if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" { + path := filepath.Join(envRoot, dbName) + if _, err := os.Stat(path); err == nil { + db, err := registry.Open(path) + if err == nil { + return db + } + } + } + + // Try root() (finds go.mod walking up from cwd) + path := filepath.Join(root(), dbName) + if _, err := os.Stat(path); err == nil { + db, err := registry.Open(path) + if err == nil { + return db + } + } + + // Try executable's directory + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + path := filepath.Join(exeDir, dbName) + if _, err := os.Stat(path); err == nil { + db, err := registry.Open(path) + if err == nil { + return db + } + } + } + + return nil +}