package main import ( "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) // Timeout general para exec de CLIs (reindex, init_jupyter_analysis...) const execTimeout = 60 * time.Second // runFN ejecuta `fn ` desde registryRoot y captura stdout+stderr. func (s *Server) runFN(args ...string) (string, error) { bin := filepath.Join(s.registryRoot, "fn") if _, err := os.Stat(bin); err != nil { return "", fmt.Errorf("fn binary not found at %s", bin) } ctx, cancel := contextWithTimeout(execTimeout) defer cancel() cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = s.registryRoot cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+s.registryRoot) out, err := cmd.CombinedOutput() return string(out), err } // runShell ejecuta un comando de shell arbitrario desde registryRoot. func (s *Server) runShell(command string) (string, error) { ctx, cancel := contextWithTimeout(execTimeout) defer cancel() cmd := exec.CommandContext(ctx, "bash", "-c", command) cmd.Dir = s.registryRoot cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+s.registryRoot) out, err := cmd.CombinedOutput() return string(out), err } // POST /api/reindex — ejecuta `fn index` y devuelve la salida. func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { out, err := s.runFN("index") if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{ "ok": false, "error": err.Error(), "output": out, }) return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "output": strings.TrimSpace(out), }) } // POST /api/add/app // Body: {"name": "my_app", "lang": "go", "domain": "core", "project": "", "description": ""} // Scaffolding minimo: crea el directorio + app.md con frontmatter. El usuario // rellena el contenido despues. Al terminar llama a `fn index`. func (s *Server) handleAddApp(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Lang string `json:"lang"` Domain string `json:"domain"` Project string `json:"project"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if !validName(req.Name) { writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") return } if req.Lang == "" { req.Lang = "go" } if req.Domain == "" { req.Domain = "core" } // Carpeta destino base := filepath.Join(s.registryRoot, "apps") if req.Project != "" { if !validName(req.Project) { writeError(w, http.StatusBadRequest, "invalid project id") return } base = filepath.Join(s.registryRoot, "projects", req.Project, "apps") } dir := filepath.Join(base, req.Name) if _, err := os.Stat(dir); err == nil { writeError(w, http.StatusConflict, "app directory already exists: "+dir) return } if err := os.MkdirAll(dir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } // dir_path relativo al repo root relDir, err := filepath.Rel(s.registryRoot, dir) if err != nil { relDir = dir } // app.md minimo appMD := fmt.Sprintf(`--- name: %s lang: %s domain: %s description: "%s" tags: [] uses_functions: [] uses_types: [] framework: "" entry_point: "" dir_path: "%s" --- # %s TODO: describir la app. `, req.Name, req.Lang, req.Domain, req.Description, filepath.ToSlash(relDir), req.Name) if err := os.WriteFile(filepath.Join(dir, "app.md"), []byte(appMD), 0o644); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } // Reindex para que aparezca en registry.db out, err := s.runFN("index") writeJSON(w, http.StatusOK, map[string]any{ "ok": err == nil, "dir": dir, "index_out": strings.TrimSpace(out), "error": errString(err), }) } // POST /api/add/analysis // Body: {"name": "my_analysis", "project": "", "packages": ["polars"], "description": ""} // Invoca el pipeline init_jupyter_analysis_bash_pipelines via `fn run`. func (s *Server) handleAddAnalysis(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Project string `json:"project"` Packages []string `json:"packages"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if !validName(req.Name) { writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") return } // fn run init_jupyter_analysis [--project

] [--desc "..."] [pkg1 pkg2 ...] args := []string{"run", "init_jupyter_analysis"} if req.Project != "" { args = append(args, "--project", req.Project) } if req.Description != "" { args = append(args, "--desc", req.Description) } args = append(args, req.Name) for _, p := range req.Packages { if p != "" { args = append(args, p) } } out, err := s.runFN(args...) writeJSON(w, http.StatusOK, map[string]any{ "ok": err == nil, "output": strings.TrimSpace(out), "error": errString(err), }) } // POST /api/add/vault // Body: {"name": "data", "project": "my_proj", "path": "/abs/path", "description": ""} // Solo valido dentro de un proyecto (vive en projects/

/vaults/). // Crea dir si no existe, aade symlink a path si se proporciona, y entrada // en vault.yaml del proyecto. func (s *Server) handleAddVault(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Project string `json:"project"` Path string `json:"path"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if !validName(req.Name) { writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") return } if req.Project == "" || !validName(req.Project) { writeError(w, http.StatusBadRequest, "project required for vault (vaults live under projects/

/vaults/)") return } projectDir := filepath.Join(s.registryRoot, "projects", req.Project) if _, err := os.Stat(projectDir); err != nil { writeError(w, http.StatusNotFound, "project dir not found: "+projectDir) return } vaultsDir := filepath.Join(projectDir, "vaults") if err := os.MkdirAll(vaultsDir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } target := filepath.Join(vaultsDir, req.Name) // Si se pasa path absoluto, crear symlink target -> path. // Si no, crear directorio real. if req.Path != "" { absPath, err := filepath.Abs(req.Path) if err != nil { writeError(w, http.StatusBadRequest, "invalid path: "+err.Error()) return } if err := os.MkdirAll(absPath, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "creating vault dir: "+err.Error()) return } if err := os.Symlink(absPath, target); err != nil && !os.IsExist(err) { writeError(w, http.StatusInternalServerError, "symlink: "+err.Error()) return } } else { if err := os.MkdirAll(target, 0o755); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } // Actualizar vault.yaml (append-o-crear). Formato simple YAML, sin parser. vaultYAML := filepath.Join(vaultsDir, "vault.yaml") entry := fmt.Sprintf("- name: %s\n description: %q\n path: %s\n tags: []\n", req.Name, req.Description, target) var existing []byte if b, err := os.ReadFile(vaultYAML); err == nil { existing = b } content := append(existing, []byte(entry)...) if err := os.WriteFile(vaultYAML, content, 0o644); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } out, err := s.runFN("index") writeJSON(w, http.StatusOK, map[string]any{ "ok": err == nil, "target": target, "index_out": strings.TrimSpace(out), "error": errString(err), }) } // Helpers func validName(s string) bool { if s == "" { return false } for _, c := range s { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { return false } } return true } func errString(err error) string { if err == nil { return "" } return err.Error() } func contextWithTimeout(d time.Duration) (ctx context.Context, cancel func()) { return context.WithTimeout(context.Background(), d) }