af13fd849c
- handlers_mutations.go: POST add_app/add_analysis/add_vault/reindex - handlers_projects.go: GET projects y project detail (apps/analysis/vaults nested) - handlers.go + main.go: cablear nuevas rutas - handlers_test.go: ajustes minimos - app.md: documentar endpoints v0.2 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
8.3 KiB
Go
303 lines
8.3 KiB
Go
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 <args...>` 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 <p>] [--desc "..."] <name> [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/<p>/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/<p>/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)
|
|
}
|