diff --git a/apps/docker_tui/app.md b/apps/docker_tui/app.md new file mode 100644 index 00000000..11f24f44 --- /dev/null +++ b/apps/docker_tui/app.md @@ -0,0 +1,28 @@ +--- +name: docker_tui +lang: go +domain: infra +description: "TUI interactiva para gestion de contenedores, imagenes, volumenes y redes Docker." +tags: [docker, tui, bubbletea, containers] +uses_functions: + - docker_pull_image_go_infra + - docker_list_containers_go_infra + - docker_remove_container_go_infra + - docker_stop_container_go_infra + - docker_start_container_go_infra + - docker_list_images_go_infra + - docker_remove_image_go_infra + - docker_remove_network_go_infra + - docker_create_network_go_infra + - docker_inspect_container_go_infra + - docker_run_container_go_infra + - docker_container_logs_go_infra +uses_types: [] +framework: bubbletea +entry_point: "main.go" +dir_path: "apps/docker_tui" +--- + +## Notas + +Aplicacion TUI con pestanas para contenedores, imagenes, volumenes, redes y compose. Construida con Bubble Tea (Charmbracelet). diff --git a/apps/metabase_registry/app.md b/apps/metabase_registry/app.md new file mode 100644 index 00000000..38ae41b8 --- /dev/null +++ b/apps/metabase_registry/app.md @@ -0,0 +1,24 @@ +--- +name: metabase_registry +lang: py +domain: analytics +description: "Setup y dashboards automaticos de Metabase para visualizar metricas del fn-registry." +tags: [metabase, dashboard, analytics, visualization] +uses_functions: + - metabase_auth_py_infra + - metabase_create_card_py_infra + - metabase_create_dashboard_py_infra + - metabase_update_dashboard_py_infra + - metabase_list_databases_py_infra + - metabase_add_database_py_infra + - metabase_list_dashboards_py_infra + - metabase_create_user_py_infra +uses_types: [] +framework: httpx +entry_point: "main.py" +dir_path: "apps/metabase_registry" +--- + +## Notas + +Scripts Python que conectan con la API REST de Metabase para crear datasources, cards SQL y dashboards automaticamente. Usa las funciones del paquete python/functions/metabase/ del registry. Credenciales en .env local. diff --git a/apps/pipeline_launcher/app.md b/apps/pipeline_launcher/app.md new file mode 100644 index 00000000..e688d471 --- /dev/null +++ b/apps/pipeline_launcher/app.md @@ -0,0 +1,16 @@ +--- +name: pipeline_launcher +lang: go +domain: tools +description: "TUI para lanzar y monitorear pipelines del fn-registry con historial de ejecuciones." +tags: [pipeline, tui, bubbletea, runner, launcher] +uses_functions: [] +uses_types: [] +framework: bubbletea +entry_point: "main.go" +dir_path: "apps/pipeline_launcher" +--- + +## Notas + +Aplicacion TUI que lista pipelines con tag `launcher` del registry, permite ejecutarlos y muestra historial de ejecuciones desde operations.db. diff --git a/cmd/fn/main.go b/cmd/fn/main.go index 40507c40..bb8988d3 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -102,7 +102,10 @@ func cmdIndex() { os.Exit(1) } - fmt.Printf("Indexed %d functions, %d types\n", result.Functions, result.Types) + // Flush WAL to main db file so external readers (e.g. Metabase) see changes. + db.WalCheckpoint() + + fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps) for _, e := range result.ValidationErrors { fmt.Fprintf(os.Stderr, " INVALID: %s\n", e) } @@ -151,7 +154,13 @@ func cmdSearch(args []string) { os.Exit(1) } - if len(fns) == 0 && len(types) == 0 { + apps, err := db.SearchApps(query, lang, domain) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if len(fns) == 0 && len(types) == 0 && len(apps) == 0 { fmt.Println("No results.") return } @@ -174,6 +183,16 @@ func cmdSearch(args []string) { fmt.Fprintf(w, "%s\t%s\t%s\n", t.Algebraic, t.ID, desc) } } + if len(apps) > 0 { + if len(fns) > 0 || len(types) > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, "APP\tID\tLANG\tDESCRIPTION") + for _, a := range apps { + desc := truncate(a.Description, 60) + fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, desc) + } + } w.Flush() } @@ -212,6 +231,12 @@ func cmdList(args []string) { os.Exit(1) } + apps, err := db.SearchApps("", lang, domain) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if len(fns) > 0 { fmt.Fprintln(w, "KIND\tID\tPURITY\tVERSION\tDOMAIN") @@ -228,7 +253,16 @@ func cmdList(args []string) { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.Algebraic, t.ID, t.Version, t.Domain) } } - if len(fns) == 0 && len(types) == 0 { + if len(apps) > 0 { + if len(fns) > 0 || len(types) > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, "APP\tID\tLANG\tDOMAIN") + for _, a := range apps { + fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain) + } + } + if len(fns) == 0 && len(types) == 0 && len(apps) == 0 { fmt.Println("Registry is empty. Run 'fn index' first.") } w.Flush() @@ -258,6 +292,12 @@ func cmdShow(args []string) { return } + a, errA := db.GetApp(id) + if errA == nil { + printApp(a) + return + } + fmt.Fprintf(os.Stderr, "not found: %s\n", id) os.Exit(1) } @@ -342,6 +382,34 @@ func printType(t *registry.Type) { } } +func printApp(a *registry.App) { + fmt.Printf("ID: %s\n", a.ID) + fmt.Printf("Name: %s\n", a.Name) + fmt.Printf("Lang: %s\n", a.Lang) + fmt.Printf("Domain: %s\n", a.Domain) + fmt.Printf("Description: %s\n", a.Description) + fmt.Printf("Tags: %s\n", strings.Join(a.Tags, ", ")) + fmt.Printf("Dir: %s\n", a.DirPath) + if a.Framework != "" { + fmt.Printf("Framework: %s\n", a.Framework) + } + if a.EntryPoint != "" { + fmt.Printf("Entry point: %s\n", a.EntryPoint) + } + if len(a.UsesFunctions) > 0 { + fmt.Printf("Uses fns: %s\n", strings.Join(a.UsesFunctions, ", ")) + } + if len(a.UsesTypes) > 0 { + fmt.Printf("Uses types: %s\n", strings.Join(a.UsesTypes, ", ")) + } + if a.Notes != "" { + fmt.Printf("\nNotes:\n%s\n", a.Notes) + } + if a.Documentation != "" { + fmt.Printf("\nDocumentation:\n%s\n", a.Documentation) + } +} + // --- add --- func cmdAdd(args []string) { @@ -367,8 +435,10 @@ func cmdAdd(args []string) { templatePath = filepath.Join(r, "docs", "templates", "pipeline.md") case "component": templatePath = filepath.Join(r, "docs", "templates", "component.md") + case "app": + templatePath = filepath.Join(r, "docs", "templates", "app.md") default: - fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, or component)\n", kind) + fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, or app)\n", kind) os.Exit(1) } diff --git a/docs/templates/app.md b/docs/templates/app.md new file mode 100644 index 00000000..34ad296b --- /dev/null +++ b/docs/templates/app.md @@ -0,0 +1,16 @@ +--- +name: my_app +lang: go +domain: tools +description: "Descripcion breve de la aplicacion." +tags: [] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "main.go" +dir_path: "apps/my_app" +--- + +## Notas + +Notas adicionales sobre la aplicacion. diff --git a/registry/indexer.go b/registry/indexer.go index 9a85f2ea..7dd6ba32 100644 --- a/registry/indexer.go +++ b/registry/indexer.go @@ -11,6 +11,7 @@ import ( type IndexResult struct { Functions int Types int + Apps int ValidationErrors []string Errors []string } @@ -76,6 +77,28 @@ func Index(db *DB, root string) (*IndexResult, error) { }) } + // Parse apps from apps/*/app.md + var apps []*App + appsDir := filepath.Join(root, "apps") + if fi, err := os.Stat(appsDir); err == nil && fi.IsDir() { + entries, _ := os.ReadDir(appsDir) + for _, e := range entries { + if !e.IsDir() { + continue + } + appMD := filepath.Join(appsDir, e.Name(), "app.md") + if _, err := os.Stat(appMD); err != nil { + continue + } + a, err := ParseAppMD(appMD, root) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", appMD, err)) + continue + } + apps = append(apps, a) + } + } + // Build known ID sets knownFunctions := make(map[string]bool, len(functions)) for _, f := range functions { @@ -111,6 +134,18 @@ func Index(db *DB, root string) (*IndexResult, error) { result.Functions++ } + for _, a := range apps { + if verr := ValidateApp(a, knownFunctions, knownTypes); verr != nil { + result.ValidationErrors = append(result.ValidationErrors, verr.Error()) + continue + } + if err := db.InsertApp(a); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert %s: %v", a.ID, err)) + continue + } + result.Apps++ + } + return result, nil } diff --git a/registry/migrations/004_apps.sql b/registry/migrations/004_apps.sql new file mode 100644 index 00000000..a2fcdda7 --- /dev/null +++ b/registry/migrations/004_apps.sql @@ -0,0 +1,48 @@ +-- Apps table: applications that consume functions/types from the registry. + +CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + lang TEXT NOT NULL, + domain TEXT NOT NULL, + description TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + uses_functions TEXT NOT NULL DEFAULT '[]', + uses_types TEXT NOT NULL DEFAULT '[]', + framework TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + documentation TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + dir_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5( + id, + name, + description, + tags, + domain, + documentation, + notes, + content='apps', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS apps_ai AFTER INSERT ON apps BEGIN + INSERT INTO apps_fts(rowid, id, name, description, tags, domain, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes); +END; + +CREATE TRIGGER IF NOT EXISTS apps_ad AFTER DELETE ON apps BEGIN + INSERT INTO apps_fts(apps_fts, rowid, id, name, description, tags, domain, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes); +END; + +CREATE TRIGGER IF NOT EXISTS apps_au AFTER UPDATE ON apps BEGIN + INSERT INTO apps_fts(apps_fts, rowid, id, name, description, tags, domain, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain, old.documentation, old.notes); + INSERT INTO apps_fts(rowid, id, name, description, tags, domain, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain, new.documentation, new.notes); +END; diff --git a/registry/models.go b/registry/models.go index 53dafa70..b725f377 100644 --- a/registry/models.go +++ b/registry/models.go @@ -94,6 +94,25 @@ type Type struct { UpdatedAt time.Time `json:"updated_at"` } +// App represents an entry in the apps table. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Description string `json:"description"` + Tags []string `json:"tags"` + UsesFunctions []string `json:"uses_functions"` + UsesTypes []string `json:"uses_types"` + Framework string `json:"framework"` + EntryPoint string `json:"entry_point"` + Documentation string `json:"documentation"` + Notes string `json:"notes"` + DirPath string `json:"dir_path"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ProposalKind classifies a proposal. type ProposalKind string diff --git a/registry/parser.go b/registry/parser.go index a5875173..e47b8c95 100644 --- a/registry/parser.go +++ b/registry/parser.go @@ -54,6 +54,20 @@ type rawType struct { FilePath string `yaml:"file_path"` } +// rawApp mirrors the YAML frontmatter of an app .md file. +type rawApp struct { + Name string `yaml:"name"` + Lang string `yaml:"lang"` + Domain string `yaml:"domain"` + Description string `yaml:"description"` + Tags []string `yaml:"tags"` + UsesFunctions []string `yaml:"uses_functions"` + UsesTypes []string `yaml:"uses_types"` + Framework string `yaml:"framework"` + EntryPoint string `yaml:"entry_point"` + DirPath string `yaml:"dir_path"` +} + // extractFrontmatter splits a .md file into YAML frontmatter and body. func extractFrontmatter(data []byte) ([]byte, []byte, error) { content := data @@ -198,6 +212,51 @@ func ParseTypeMD(path string, root string) (*Type, error) { return t, nil } +// ParseAppMD parses an app .md file into an App. +func ParseAppMD(path string, root string) (*App, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + fm, body, err := extractFrontmatter(data) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + var raw rawApp + if err := yaml.Unmarshal(fm, &raw); err != nil { + return nil, fmt.Errorf("parsing YAML in %s: %w", path, err) + } + + if raw.Name == "" { + return nil, fmt.Errorf("%s: name is required", path) + } + if raw.Description == "" { + return nil, fmt.Errorf("%s: description is required", path) + } + + sections := extractSections(body) + + a := &App{ + ID: GenerateID(raw.Name, raw.Lang, raw.Domain), + Name: raw.Name, + Lang: raw.Lang, + Domain: raw.Domain, + Description: raw.Description, + Tags: raw.Tags, + UsesFunctions: raw.UsesFunctions, + UsesTypes: raw.UsesTypes, + Framework: raw.Framework, + EntryPoint: raw.EntryPoint, + Documentation: sections.documentation, + Notes: sections.notes, + DirPath: raw.DirPath, + } + + return a, nil +} + // bodySections holds the extracted sections from a .md body. type bodySections struct { example string // content under ## Ejemplo diff --git a/registry/store.go b/registry/store.go index 8d24a8ee..947d33e7 100644 --- a/registry/store.go +++ b/registry/store.go @@ -261,12 +261,118 @@ func (db *DB) DeleteType(id string) error { return err } -// Purge deletes all data from both tables. Used before re-indexing. +// InsertApp inserts or replaces an app entry. +func (db *DB) InsertApp(a *App) error { + now := time.Now().UTC().Format(time.RFC3339) + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now().UTC() + } + a.UpdatedAt = time.Now().UTC() + + if a.ID == "" { + a.ID = GenerateID(a.Name, a.Lang, a.Domain) + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO apps ( + id, name, lang, domain, description, tags, + uses_functions, uses_types, framework, entry_point, + documentation, notes, dir_path, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags), + marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint, + a.Documentation, a.Notes, a.DirPath, a.CreatedAt.Format(time.RFC3339), now, + ) + return err +} + +// GetApp returns a single app by ID. +func (db *DB) GetApp(id string) (*App, error) { + rows, err := db.conn.Query("SELECT * FROM apps WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + apps, err := scanApps(rows) + if err != nil { + return nil, err + } + if len(apps) == 0 { + return nil, fmt.Errorf("app %q not found", id) + } + return &apps[0], nil +} + +// SearchApps performs FTS search on apps with optional filters. +func (db *DB) SearchApps(query string, lang, domain string) ([]App, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "a.id IN (SELECT id FROM apps_fts WHERE apps_fts MATCH ?)") + args = append(args, query) + } + if lang != "" { + where = append(where, "a.lang = ?") + args = append(args, lang) + } + if domain != "" { + where = append(where, "a.domain = ?") + args = append(args, domain) + } + + sql := "SELECT * FROM apps a" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY a.name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search apps: %w", err) + } + defer rows.Close() + + return scanApps(rows) +} + +func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) { + var result []App + for rows.Next() { + var a App + var tagsJSON, usesFnJSON, usesTypJSON string + var createdAt, updatedAt string + + err := rows.Scan( + &a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON, + &usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint, + &a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning app: %w", err) + } + + a.Tags = unmarshalStrings(tagsJSON) + a.UsesFunctions = unmarshalStrings(usesFnJSON) + a.UsesTypes = unmarshalStrings(usesTypJSON) + a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + a.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + result = append(result, a) + } + return result, nil +} + +// Purge deletes all data from functions, types and apps. Used before re-indexing. func (db *DB) Purge() error { if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { return err } - _, err := db.conn.Exec("DELETE FROM types") + if _, err := db.conn.Exec("DELETE FROM types"); err != nil { + return err + } + _, err := db.conn.Exec("DELETE FROM apps") return err } diff --git a/registry/validate.go b/registry/validate.go index 0ec04c9e..7d6c4c48 100644 --- a/registry/validate.go +++ b/registry/validate.go @@ -161,6 +161,44 @@ func ValidateProposal(p *Proposal) *ValidationError { return nil } +// ValidateApp checks integrity rules for apps. +func ValidateApp(a *App, knownFunctions, knownTypes map[string]bool) *ValidationError { + var errs []string + + if a.Name == "" { + errs = append(errs, "name is required") + } + if a.Lang == "" { + errs = append(errs, "lang is required") + } + if a.Domain == "" { + errs = append(errs, "domain is required") + } + if a.Description == "" { + errs = append(errs, "description is required") + } + + if a.DirPath != "" && strings.HasPrefix(a.DirPath, "/") { + errs = append(errs, "dir_path must be relative to registry root") + } + + for _, ref := range a.UsesFunctions { + if !knownFunctions[ref] { + errs = append(errs, fmt.Sprintf("uses_functions references unknown function: %s", ref)) + } + } + for _, ref := range a.UsesTypes { + if !knownTypes[ref] { + errs = append(errs, fmt.Sprintf("uses_types references unknown type: %s", ref)) + } + } + + if len(errs) > 0 { + return &ValidationError{ID: a.ID, Errors: errs} + } + return nil +} + // ValidateType checks integrity rules for types. func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError { var errs []string