feat: externalize apps/analysis to Gitea repos, add analysis table
- Migration 007: repo_url on apps table + analysis table with FTS5 - Analysis struct, parser, CRUD, validation, hash computation - Selective purge: remote-only apps/analysis preserved across fn index - CLI: fn app list/clone/pull, fn analysis list/clone/pull - search/show/list now include analysis results - Apps removed from git tracking (content lives in Gitea repos) - .gitkeep for apps/ and analysis/ dirs - Bash functions: jupyter analysis pipeline, shell utilities - Browser domain: CDP functions moved from infra to browser Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func cmdAnalysis(args []string) {
|
||||
if len(args) < 1 {
|
||||
printAnalysisUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
analysisList()
|
||||
case "clone":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn analysis clone <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
analysisClone(args[1])
|
||||
case "pull":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn analysis pull <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
analysisPull(args[1])
|
||||
case "help", "-h":
|
||||
printAnalysisUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown analysis command: %s\n", args[0])
|
||||
printAnalysisUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printAnalysisUsage() {
|
||||
fmt.Println(`fn analysis — manage registry analyses
|
||||
|
||||
Usage:
|
||||
fn analysis list List all analyses (local + remote)
|
||||
fn analysis clone <id> Clone analysis repo to analysis/<name>/
|
||||
fn analysis pull <id> Git pull in existing clone`)
|
||||
}
|
||||
|
||||
func analysisList() {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
items, err := db.ListAllAnalysis()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Println("No analyses registered.")
|
||||
return
|
||||
}
|
||||
|
||||
r := root()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
|
||||
for _, a := range items {
|
||||
status := "local"
|
||||
if a.RepoURL != "" {
|
||||
dirPath := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
||||
status = "remote"
|
||||
} else {
|
||||
status = "cloned"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func analysisClone(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetAnalysis(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if a.RepoURL == "" {
|
||||
fmt.Fprintf(os.Stderr, "analysis %s has no repo_url set\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
target := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
|
||||
cmd := exec.Command("git", "clone", a.RepoURL, target)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
func analysisPull(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetAnalysis(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
dir := filepath.Join(r, "analysis", a.Name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "analysis not cloned locally: %s\n", dir)
|
||||
fmt.Fprintln(os.Stderr, "Run 'fn analysis clone "+id+"' first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulling %s\n", dir)
|
||||
cmd := exec.Command("git", "-C", dir, "pull")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func cmdApp(args []string) {
|
||||
if len(args) < 1 {
|
||||
printAppUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
appList()
|
||||
case "clone":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn app clone <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
appClone(args[1])
|
||||
case "pull":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fn app pull <id>")
|
||||
os.Exit(1)
|
||||
}
|
||||
appPull(args[1])
|
||||
case "help", "-h":
|
||||
printAppUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown app command: %s\n", args[0])
|
||||
printAppUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printAppUsage() {
|
||||
fmt.Println(`fn app — manage registry apps
|
||||
|
||||
Usage:
|
||||
fn app list List all apps (local + remote)
|
||||
fn app clone <id> Clone app repo to apps/<name>/
|
||||
fn app pull <id> Git pull in existing clone`)
|
||||
}
|
||||
|
||||
func appList() {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
apps, err := db.ListAllApps()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
fmt.Println("No apps registered.")
|
||||
return
|
||||
}
|
||||
|
||||
r := root()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tSTATUS\tREPO_URL")
|
||||
for _, a := range apps {
|
||||
status := "local"
|
||||
if a.RepoURL != "" {
|
||||
dirPath := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
||||
status = "remote"
|
||||
} else {
|
||||
status = "cloned"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain, status, a.RepoURL)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func appClone(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetApp(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if a.RepoURL == "" {
|
||||
fmt.Fprintf(os.Stderr, "app %s has no repo_url set\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
target := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "directory already exists: %s\n", target)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Cloning %s → %s\n", a.RepoURL, target)
|
||||
cmd := exec.Command("git", "clone", a.RepoURL, target)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git clone failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
func appPull(id string) {
|
||||
db := openDB()
|
||||
defer db.Close()
|
||||
|
||||
a, err := db.GetApp(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
r := root()
|
||||
dir := filepath.Join(r, "apps", a.Name)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "app not cloned locally: %s\n", dir)
|
||||
fmt.Fprintln(os.Stderr, "Run 'fn app clone "+id+"' first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulling %s\n", dir)
|
||||
cmd := exec.Command("git", "-C", dir, "pull")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "git pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Done. Run 'fn index' to re-index.")
|
||||
}
|
||||
|
||||
// formatRepoURL ensures the URL does not contain inline credentials for display.
|
||||
func formatRepoURL(url string) string {
|
||||
if strings.Contains(url, "@") && strings.Contains(url, "://") {
|
||||
// Mask credentials in URLs like https://user:pass@host/...
|
||||
parts := strings.SplitN(url, "://", 2)
|
||||
if len(parts) == 2 {
|
||||
atIdx := strings.LastIndex(parts[1], "@")
|
||||
if atIdx >= 0 {
|
||||
return parts[0] + "://" + parts[1][atIdx+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
+84
-5
@@ -35,6 +35,10 @@ func main() {
|
||||
cmdProposal(os.Args[2:])
|
||||
case "run":
|
||||
cmdRun(os.Args[2:])
|
||||
case "app":
|
||||
cmdApp(os.Args[2:])
|
||||
case "analysis":
|
||||
cmdAnalysis(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
@@ -55,7 +59,9 @@ Usage:
|
||||
fn add [-k kind] Abre $EDITOR con template
|
||||
fn run <id_or_name> [args...] Ejecuta funcion/pipeline (go/py/bash)
|
||||
fn ops <subcommand> Gestiona operations.db (fn ops help)
|
||||
fn proposal <add|list|show|update> Gestiona proposals`)
|
||||
fn proposal <add|list|show|update> Gestiona proposals
|
||||
fn app <list|clone|pull> Gestiona apps externas (Gitea)
|
||||
fn analysis <list|clone|pull> Gestiona analyses externas (Gitea)`)
|
||||
}
|
||||
|
||||
func root() string {
|
||||
@@ -117,7 +123,7 @@ func cmdIndex() {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Indexed %d functions, %d types, %d apps\n", result.Functions, result.Types, result.Apps)
|
||||
fmt.Printf("Indexed %d functions, %d types, %d apps, %d analysis\n", result.Functions, result.Types, result.Apps, result.Analysis)
|
||||
for _, e := range result.ValidationErrors {
|
||||
fmt.Fprintf(os.Stderr, " INVALID: %s\n", e)
|
||||
}
|
||||
@@ -172,7 +178,13 @@ func cmdSearch(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 {
|
||||
analyses, err := db.SearchAnalysis(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 && len(analyses) == 0 {
|
||||
fmt.Println("No results.")
|
||||
return
|
||||
}
|
||||
@@ -205,6 +217,16 @@ func cmdSearch(args []string) {
|
||||
fmt.Fprintf(w, "app\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
|
||||
}
|
||||
}
|
||||
if len(analyses) > 0 {
|
||||
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDESCRIPTION")
|
||||
for _, a := range analyses {
|
||||
desc := truncate(a.Description, 60)
|
||||
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, desc)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
@@ -249,6 +271,12 @@ func cmdList(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
analyses, err := db.SearchAnalysis("", 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")
|
||||
@@ -274,7 +302,16 @@ func cmdList(args []string) {
|
||||
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 {
|
||||
if len(analyses) > 0 {
|
||||
if len(fns) > 0 || len(types) > 0 || len(apps) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "ANALYSIS\tID\tLANG\tDOMAIN")
|
||||
for _, a := range analyses {
|
||||
fmt.Fprintf(w, "analysis\t%s\t%s\t%s\n", a.ID, a.Lang, a.Domain)
|
||||
}
|
||||
}
|
||||
if len(fns) == 0 && len(types) == 0 && len(apps) == 0 && len(analyses) == 0 {
|
||||
fmt.Println("Registry is empty. Run 'fn index' first.")
|
||||
}
|
||||
w.Flush()
|
||||
@@ -310,6 +347,12 @@ func cmdShow(args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
an, errAn := db.GetAnalysis(id)
|
||||
if errAn == nil {
|
||||
printAnalysisEntry(an)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "not found: %s\n", id)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -412,6 +455,40 @@ func printApp(a *registry.App) {
|
||||
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.RepoURL != "" {
|
||||
fmt.Printf("Repo URL: %s\n", a.RepoURL)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func printAnalysisEntry(a *registry.Analysis) {
|
||||
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.RepoURL != "" {
|
||||
fmt.Printf("Repo URL: %s\n", a.RepoURL)
|
||||
}
|
||||
if a.Framework != "" {
|
||||
fmt.Printf("Framework: %s\n", a.Framework)
|
||||
}
|
||||
@@ -459,8 +536,10 @@ func cmdAdd(args []string) {
|
||||
templatePath = filepath.Join(r, "docs", "templates", "component.md")
|
||||
case "app":
|
||||
templatePath = filepath.Join(r, "docs", "templates", "app.md")
|
||||
case "analysis":
|
||||
templatePath = filepath.Join(r, "docs", "templates", "analysis.md")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, or app)\n", kind)
|
||||
fmt.Fprintf(os.Stderr, "unknown kind: %s (use function, pipeline, component, app, or analysis)\n", kind)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user