Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Add vector(N) column type to db_tables manifests with three-tier progressive enhancement: native pgvector on Postgres, JSONB fallback without pgvector, TEXT fallback on SQLite. New db.query_similar() Starlark builtin with dual-path dispatch. - parseVectorDim validates 1..4096 dimensions - mapColType gains hasPgvector parameter for tier selection - HNSW index auto-created on pgvector backends - starlarkToGoValue extended with list→JSON serialization - cosineDistance helper for Go-side fallback computation - ExtensionHandler gains SetCapabilities for install-time DDL - Roadmap updated: v0.8.4 docs refresh + surface sizing fix 13 new tests (5 schema + 8 db module), all passing with -race. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1257 lines
37 KiB
Go
1257 lines
37 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.starlark.net/starlark"
|
|
|
|
"armature/auth"
|
|
"armature/database"
|
|
"armature/events"
|
|
"armature/models"
|
|
"armature/sandbox"
|
|
"armature/store"
|
|
"armature/triggers"
|
|
)
|
|
|
|
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
|
|
// No leading/trailing hyphens, 2-64 chars.
|
|
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
|
|
|
// PackageHandler manages unified package lifecycle (admin-only).
|
|
// Replaces SurfaceHandler.
|
|
type PackageHandler struct {
|
|
stores store.Stores
|
|
packagesDir string // e.g. /data/packages — where archives are extracted
|
|
bundledDir string // e.g. /app/bundled-packages — .pkg archive source
|
|
sandbox *sandbox.Sandbox
|
|
runner *sandbox.Runner
|
|
hub *events.Hub
|
|
capabilities map[string]bool // detected environment capabilities
|
|
}
|
|
|
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
|
dir := ""
|
|
if len(packagesDir) > 0 {
|
|
dir = packagesDir[0]
|
|
}
|
|
return &PackageHandler{stores: s, packagesDir: dir}
|
|
}
|
|
|
|
// SetBundledDir sets the path to the bundled packages directory.
|
|
// Used by dependency auto-activation to resolve missing dependencies.
|
|
func (h *PackageHandler) SetBundledDir(dir string) {
|
|
h.bundledDir = dir
|
|
}
|
|
|
|
// SetSandbox attaches a Starlark sandbox for schema migrations.
|
|
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
|
h.sandbox = sb
|
|
}
|
|
|
|
// SetRunner attaches the Starlark runner for test-tool execution.
|
|
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
|
|
h.runner = r
|
|
}
|
|
|
|
// SetHub attaches the event hub for broadcasting package lifecycle events.
|
|
func (h *PackageHandler) SetHub(hub *events.Hub) {
|
|
h.hub = hub
|
|
}
|
|
|
|
// SetCapabilities stores the detected environment capabilities map.
|
|
// Used at install time to validate manifest capabilities.required.
|
|
func (h *PackageHandler) SetCapabilities(caps map[string]bool) {
|
|
h.capabilities = caps
|
|
}
|
|
|
|
// broadcastPackageChanged emits a package.changed event to all clients.
|
|
func (h *PackageHandler) broadcastPackageChanged(action, id string) {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
h.hub.Broadcast(events.Event{
|
|
Label: "package.changed",
|
|
Payload: events.MustJSON(map[string]string{"action": action, "id": id}),
|
|
Ts: time.Now().UnixMilli(),
|
|
})
|
|
}
|
|
|
|
// ListPackages returns all registered packages.
|
|
// GET /api/v1/admin/packages
|
|
func (h *PackageHandler) ListPackages(c *gin.Context) {
|
|
typeFilter := c.Query("type")
|
|
|
|
var pkgs []store.PackageRegistration
|
|
var err error
|
|
if typeFilter != "" {
|
|
pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
|
|
} else {
|
|
pkgs, err = h.stores.Packages.List(c.Request.Context())
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
|
return
|
|
}
|
|
if pkgs == nil {
|
|
pkgs = []store.PackageRegistration{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": pkgs})
|
|
}
|
|
|
|
// GetPackage returns a single package by ID.
|
|
// GET /api/v1/admin/packages/:id
|
|
func (h *PackageHandler) GetPackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, pkg)
|
|
}
|
|
|
|
// EnablePackage enables a package.
|
|
// PUT /api/v1/admin/packages/:id/enable
|
|
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
if pkg.Status == "dormant" {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
|
h.broadcastPackageChanged("enabled", id)
|
|
}
|
|
|
|
// DisablePackage disables a package. Admin cannot be disabled.
|
|
// PUT /api/v1/admin/packages/:id/disable
|
|
func (h *PackageHandler) DisablePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
if id == "admin" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
|
|
h.broadcastPackageChanged("disabled", id)
|
|
}
|
|
|
|
// DeletePackage uninstalls a package. Core packages cannot be deleted.
|
|
// DELETE /api/v1/admin/packages/:id
|
|
func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
if pkg.Source == "core" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"})
|
|
return
|
|
}
|
|
|
|
if pkg.Type == "library" && h.stores.Dependencies != nil {
|
|
hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
|
|
if hasConsumers {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if h.stores.Dependencies != nil {
|
|
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
|
|
}
|
|
|
|
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
|
|
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
|
|
}
|
|
|
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
|
return
|
|
}
|
|
|
|
// Unregister user-facing permissions from the dynamic registry
|
|
auth.UnregisterExtensionPermissions(id)
|
|
|
|
// Clean up extracted static assets
|
|
if h.packagesDir != "" {
|
|
assetDir := filepath.Join(h.packagesDir, id)
|
|
if err := os.RemoveAll(assetDir); err != nil {
|
|
log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
|
h.broadcastPackageChanged("deleted", id)
|
|
}
|
|
|
|
// InstallPackage uploads and installs a .pkg/.surface archive.
|
|
// POST /api/v1/admin/packages/install
|
|
//
|
|
// Also supports pre-downloaded files via gin context:
|
|
//
|
|
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
|
// c.Set("_registry_source", "registry") — override source
|
|
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|
// Phase 1: Receive upload → temp file
|
|
tmpPath, cleanupTmp, err := h.receiveUpload(c)
|
|
if err != nil {
|
|
return // error already sent
|
|
}
|
|
if cleanupTmp {
|
|
defer os.Remove(tmpPath)
|
|
}
|
|
|
|
// Phase 2: Parse and validate archive
|
|
zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath)
|
|
if err != nil {
|
|
return // error already sent
|
|
}
|
|
defer zr.Close()
|
|
pkgID := mInfo.ID
|
|
|
|
// Phase 3: Check for conflicts with core packages
|
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
if existing != nil && existing.Source == "core" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
|
return
|
|
}
|
|
|
|
// Phase 4: Extract static assets to disk
|
|
h.extractPackageAssets(pkgID, zr)
|
|
|
|
// Phase 5: Register in database
|
|
userID := c.GetString("user_id")
|
|
pkgSource := "extension"
|
|
if src, ok := c.Get("_registry_source"); ok {
|
|
pkgSource = src.(string)
|
|
}
|
|
preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest)
|
|
if err != nil {
|
|
return // error already sent
|
|
}
|
|
|
|
// Phase 6: Apply DDL, permissions, triggers, schema migrations
|
|
if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil {
|
|
return // error already sent
|
|
}
|
|
|
|
// Phase 7: Install workflow definition (if workflow type)
|
|
if mInfo.Type == "workflow" {
|
|
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
|
|
log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Phase 8: Resolve dependencies
|
|
if err := h.resolveDependencies(c, pkgID, manifest); err != nil {
|
|
return // error already sent
|
|
}
|
|
|
|
// Phase 9: Auto-set default surface
|
|
if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" {
|
|
if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
|
|
dflt := models.JSONMap{"id": pkgID}
|
|
if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
|
|
log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
|
} else {
|
|
log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 10: Validate required capabilities
|
|
if caps, ok := ParseCapabilities(manifest); ok {
|
|
missing := CheckRequiredCapabilities(caps.Required, h.capabilities)
|
|
if len(missing) > 0 {
|
|
h.stores.Packages.Delete(c.Request.Context(), pkgID)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
|
"error": "missing required capabilities",
|
|
"missing": missing,
|
|
"help": capabilityHelpText(missing),
|
|
})
|
|
return
|
|
}
|
|
unmetOpt := CheckRequiredCapabilities(caps.Optional, h.capabilities)
|
|
if len(unmetOpt) > 0 {
|
|
log.Printf("[packages] %s: optional capabilities unavailable: %v", pkgID, unmetOpt)
|
|
}
|
|
}
|
|
|
|
resp := gin.H{
|
|
"id": pkgID,
|
|
"title": mInfo.Title,
|
|
"type": mInfo.Type,
|
|
"version": mInfo.Version,
|
|
"source": pkgSource,
|
|
"enabled": preservedEnabled,
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
h.broadcastPackageChanged("installed", pkgID)
|
|
}
|
|
|
|
// ── InstallPackage phases ────────────────────────
|
|
|
|
// receiveUpload reads the uploaded file into a temp file.
|
|
// Returns the path, whether to clean up, and any error (already sent to client).
|
|
func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) {
|
|
if regFile, ok := c.Get("_registry_file"); ok {
|
|
return regFile.(string), false, nil
|
|
}
|
|
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
|
return "", false, err
|
|
}
|
|
defer file.Close()
|
|
|
|
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
|
strings.HasSuffix(header.Filename, ".surface") ||
|
|
strings.HasSuffix(header.Filename, ".zip")
|
|
if !validExt {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
|
return "", false, fmt.Errorf("invalid extension")
|
|
}
|
|
if header.Size > 50*1024*1024 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
|
return "", false, fmt.Errorf("too large")
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
|
return "", false, err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
|
return "", false, err
|
|
}
|
|
tmpFile.Close()
|
|
return tmpPath, true, nil
|
|
}
|
|
|
|
// parseAndValidateArchive opens the zip, parses manifest.json, validates
|
|
// starlark entry points, runs unicode security scans, and validates manifest structure.
|
|
func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) {
|
|
zr, err := zip.OpenReader(tmpPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
// Find and parse manifest.json
|
|
var manifest map[string]any
|
|
for _, f := range zr.File {
|
|
name := filepath.Base(f.Name)
|
|
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
zr.Close()
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
|
return nil, nil, nil, err
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if manifest == nil {
|
|
zr.Close()
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
|
return nil, nil, nil, fmt.Errorf("missing manifest")
|
|
}
|
|
|
|
// Validate starlark entry point
|
|
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
|
|
entryPoint := "script.star"
|
|
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
|
|
entryPoint = ep
|
|
}
|
|
found := false
|
|
for _, f := range zr.File {
|
|
base := filepath.Base(f.Name)
|
|
if base == entryPoint && !f.FileInfo().IsDir() {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
zr.Close()
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
|
|
return nil, nil, nil, fmt.Errorf("missing entry point")
|
|
}
|
|
}
|
|
|
|
// Unicode security scan
|
|
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
if !scanExts[ext] {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
findings := sandbox.ScanSource(string(data), f.Name)
|
|
if len(findings) > 0 {
|
|
if blocked, reason := sandbox.Verdict(findings); blocked {
|
|
zr.Close()
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
|
"error": "extension_blocked",
|
|
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
|
|
})
|
|
return nil, nil, nil, fmt.Errorf("blocked")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate manifest structure
|
|
mInfo, err := ValidateManifest(manifest)
|
|
if err != nil {
|
|
zr.Close()
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
// Signing hook (reserved)
|
|
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
|
|
if mInfo.Signature != "" {
|
|
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature)
|
|
} else {
|
|
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID)
|
|
}
|
|
}
|
|
|
|
return zr, manifest, mInfo, nil
|
|
}
|
|
|
|
// extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory.
|
|
func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) {
|
|
if h.packagesDir == "" {
|
|
return
|
|
}
|
|
destDir := filepath.Join(h.packagesDir, pkgID)
|
|
os.MkdirAll(destDir, 0755)
|
|
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
relPath := extractableRelPath(f.Name)
|
|
if relPath == "" {
|
|
continue
|
|
}
|
|
destPath := filepath.Join(destDir, relPath)
|
|
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
|
continue // path traversal
|
|
}
|
|
os.MkdirAll(filepath.Dir(destPath), 0755)
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out, err := os.Create(destPath)
|
|
if err != nil {
|
|
rc.Close()
|
|
continue
|
|
}
|
|
io.Copy(out, rc)
|
|
out.Close()
|
|
rc.Close()
|
|
}
|
|
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
|
}
|
|
|
|
// registerPackage seeds the package in the database and updates metadata fields.
|
|
// Returns the preserved enabled state and any error (already sent to client).
|
|
func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) {
|
|
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
|
return false, err
|
|
}
|
|
|
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
preservedEnabled := true
|
|
if existing != nil {
|
|
preservedEnabled = existing.Enabled
|
|
}
|
|
|
|
pkg := &store.PackageRegistration{
|
|
Title: mInfo.Title,
|
|
Type: mInfo.Type,
|
|
Version: mInfo.Version,
|
|
Description: mInfo.Description,
|
|
Author: mInfo.Author,
|
|
Tier: mInfo.Tier,
|
|
IsSystem: false,
|
|
Enabled: preservedEnabled,
|
|
Manifest: manifest,
|
|
}
|
|
if userID != "" {
|
|
pkg.InstalledBy = &userID
|
|
}
|
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
|
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
|
}
|
|
return preservedEnabled, nil
|
|
}
|
|
|
|
// applySchemaAndPermissions creates extension tables, syncs permissions/triggers,
|
|
// and runs schema migrations.
|
|
func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error {
|
|
if tables, ok := ParseDBTables(manifest); ok {
|
|
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables, h.capabilities["pgvector"]); err != nil {
|
|
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
|
|
}
|
|
}
|
|
|
|
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
|
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
|
|
|
newSchemaVersion := ParseSchemaVersion(manifest)
|
|
if newSchemaVersion > 0 && h.sandbox != nil {
|
|
oldSchemaVersion := 0
|
|
if existing != nil {
|
|
oldSchemaVersion = existing.SchemaVersion
|
|
}
|
|
if newSchemaVersion < oldSchemaVersion {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
|
oldSchemaVersion, newSchemaVersion),
|
|
})
|
|
return fmt.Errorf("downgrade")
|
|
}
|
|
if newSchemaVersion > oldSchemaVersion {
|
|
if err := RunSchemaMigrations(
|
|
c.Request.Context(), h.sandbox, h.stores,
|
|
database.DB, database.IsPostgres(),
|
|
pkgID, manifest, oldSchemaVersion, newSchemaVersion,
|
|
); err != nil {
|
|
log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "schema migration failed: " + err.Error(),
|
|
})
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveDependencies validates and records library dependencies.
|
|
func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error {
|
|
deps, ok := manifest["dependencies"].(map[string]any)
|
|
if !ok || len(deps) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if h.stores.Dependencies != nil {
|
|
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
|
}
|
|
|
|
for libID, vSpec := range deps {
|
|
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
|
if err != nil || lib == nil {
|
|
// Attempt auto-install from bundled packages
|
|
if h.bundledDir != "" {
|
|
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
|
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
|
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
|
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id"), h.capabilities); installErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
|
})
|
|
return installErr
|
|
}
|
|
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
|
}
|
|
}
|
|
if lib == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
|
return fmt.Errorf("missing dep: %s", libID)
|
|
}
|
|
}
|
|
if lib.Type != "library" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
|
return fmt.Errorf("not a library: %s", libID)
|
|
}
|
|
if lib.Status == "suspended" || lib.Status == "dormant" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
|
return fmt.Errorf("dep %s is %s", libID, lib.Status)
|
|
}
|
|
versionSpec, _ := vSpec.(string)
|
|
if versionSpec == "" {
|
|
versionSpec = ">=0.0.0"
|
|
}
|
|
if h.stores.Dependencies != nil {
|
|
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
|
ConsumerID: pkgID,
|
|
LibraryID: libID,
|
|
VersionSpec: versionSpec,
|
|
ResolvedVer: lib.Version,
|
|
}); err != nil {
|
|
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
|
return nil
|
|
}
|
|
|
|
|
|
// extractableRelPath returns the relative path for a zip entry if it
|
|
// belongs to an extractable directory (js/, css/, assets/, star/) or
|
|
// is a bare .star file at the archive root.
|
|
// Returns "" if the file should be skipped.
|
|
func extractableRelPath(name string) string {
|
|
staticPrefixes := []string{"js/", "css/", "assets/", "star/"}
|
|
|
|
// Direct match (flat archive without package-id/ wrapper)
|
|
for _, p := range staticPrefixes {
|
|
if strings.HasPrefix(name, p) {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Bare .star file at root (e.g. "script.star")
|
|
if strings.HasSuffix(name, ".star") && !strings.Contains(name, "/") {
|
|
return name
|
|
}
|
|
|
|
// Nested inside package-id/ directory
|
|
idx := strings.Index(name, "/")
|
|
if idx <= 0 {
|
|
return ""
|
|
}
|
|
|
|
rest := name[idx+1:]
|
|
for _, p := range staticPrefixes {
|
|
if strings.HasPrefix(rest, p) {
|
|
return rest
|
|
}
|
|
}
|
|
|
|
// Bare .star file inside package-id/ (e.g. "gitea-client/script.star")
|
|
if strings.HasSuffix(rest, ".star") && !strings.Contains(rest, "/") {
|
|
return rest
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ── Package settings ──────────────
|
|
|
|
// GetPackageSettings returns the settings schema from the manifest merged
|
|
// with current admin-configured values.
|
|
// GET /api/v1/admin/packages/:id/settings
|
|
func (h *PackageHandler) GetPackageSettings(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
// Extract settings schema from manifest
|
|
schema, _ := pkg.Manifest["settings"].([]any)
|
|
|
|
// Get current values
|
|
current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
|
|
if err != nil {
|
|
current = json.RawMessage("{}")
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"schema": schema,
|
|
"values": json.RawMessage(current),
|
|
})
|
|
}
|
|
|
|
// UpdatePackageSettings validates and stores admin-configured package settings.
|
|
// PUT /api/v1/admin/packages/:id/settings
|
|
func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
// Validate keys against declared schema
|
|
schemaArr, _ := pkg.Manifest["settings"].([]any)
|
|
validKeys := make(map[string]map[string]any, len(schemaArr))
|
|
for _, s := range schemaArr {
|
|
if entry, ok := s.(map[string]any); ok {
|
|
if key, ok := entry["key"].(string); ok {
|
|
validKeys[key] = entry
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter to only declared keys and type-check
|
|
filtered := make(map[string]any, len(body))
|
|
for k, v := range body {
|
|
schemaDef, ok := validKeys[k]
|
|
if !ok {
|
|
continue // skip undeclared keys
|
|
}
|
|
settingType, _ := schemaDef["type"].(string)
|
|
if validateSettingType(v, settingType) {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
|
|
settingsJSON, _ := json.Marshal(filtered)
|
|
if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)})
|
|
}
|
|
|
|
// validateSettingType checks if a value matches the expected setting type.
|
|
func validateSettingType(v any, settingType string) bool {
|
|
switch settingType {
|
|
case "string", "select":
|
|
_, ok := v.(string)
|
|
return ok
|
|
case "number":
|
|
_, ok := v.(float64)
|
|
return ok
|
|
case "boolean":
|
|
_, ok := v.(bool)
|
|
return ok
|
|
default:
|
|
return true // unknown types pass through
|
|
}
|
|
}
|
|
|
|
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
|
// GET /api/v1/surfaces
|
|
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
|
pkgs, err := h.stores.Packages.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
|
|
return
|
|
}
|
|
|
|
type navSurface struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Route string `json:"route"`
|
|
Icon string `json:"icon,omitempty"`
|
|
}
|
|
|
|
var enabled []navSurface
|
|
for _, p := range pkgs {
|
|
if !p.Enabled {
|
|
continue
|
|
}
|
|
// Only surface and full types have routes
|
|
if p.Type != "surface" && p.Type != "full" {
|
|
continue
|
|
}
|
|
// Welcome is a fallback surface, not a navigable destination
|
|
if p.ID == "welcome" {
|
|
continue
|
|
}
|
|
route, _ := p.Manifest["route"].(string)
|
|
icon, _ := p.Manifest["icon"].(string)
|
|
enabled = append(enabled, navSurface{
|
|
ID: p.ID,
|
|
Title: p.Title,
|
|
Route: route,
|
|
Icon: icon,
|
|
})
|
|
}
|
|
if enabled == nil {
|
|
enabled = []navSurface{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
|
}
|
|
|
|
// ── Test Tool ─────────────────────────
|
|
// POST /admin/packages/:id/test-tool
|
|
// Invokes a starlark extension's on_tool_call entry point directly,
|
|
// without going through the AI chat loop. Admin-only test harness.
|
|
|
|
// TestToolRequest is the JSON body for test-tool invocation.
|
|
type TestToolRequest struct {
|
|
ToolName string `json:"tool_name" binding:"required"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
|
|
// TestTool invokes a package's on_tool_call entry point for testing.
|
|
func (h *PackageHandler) TestTool(c *gin.Context) {
|
|
if h.runner == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
|
|
return
|
|
}
|
|
|
|
pkgID := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
if pkg.Tier != "starlark" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
|
|
return
|
|
}
|
|
if !pkg.Enabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
|
|
return
|
|
}
|
|
|
|
var req TestToolRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build a call dict identical to what executeExtensionTool sends.
|
|
// Build the arguments as an interface{} map for jsonToStarlark.
|
|
var args interface{}
|
|
if req.Arguments != nil {
|
|
argsJSON, _ := json.Marshal(req.Arguments)
|
|
_ = json.Unmarshal(argsJSON, &args)
|
|
}
|
|
|
|
callDict := starlark.NewDict(3)
|
|
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
|
|
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
|
|
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
|
|
|
|
userID, _ := c.Get("user_id")
|
|
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
|
|
|
|
val, output, err := h.runner.CallEntryPoint(
|
|
c.Request.Context(), pkg, "on_tool_call",
|
|
starlark.Tuple{callDict}, nil, rc,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": err.Error(),
|
|
"output": output,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Serialize the Starlark return value to Go types.
|
|
result := starlarkValueToGo(val)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"result": result,
|
|
"output": output,
|
|
})
|
|
}
|
|
|
|
// ── Package update ──────────────────────
|
|
|
|
// UpdatePackage applies an in-place update to an existing package.
|
|
// POST /api/v1/admin/packages/:id/update
|
|
//
|
|
// Validates version bump (semver), applies additive schema migration,
|
|
// merges settings, replaces assets, and re-syncs permissions/triggers.
|
|
func (h *PackageHandler) UpdatePackage(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
pkgID := c.Param("id")
|
|
|
|
// 1. Lookup existing package
|
|
existing, err := h.stores.Packages.Get(ctx, pkgID)
|
|
if err != nil || existing == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
if existing.Source == "core" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot update core packages"})
|
|
return
|
|
}
|
|
|
|
// 2. Accept file upload
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
|
strings.HasSuffix(header.Filename, ".surface") ||
|
|
strings.HasSuffix(header.Filename, ".zip")
|
|
if !validExt {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
|
return
|
|
}
|
|
if header.Size > 50*1024*1024 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
|
return
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "package-update-*.zip")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
|
return
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
|
tmpFile.Close()
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
|
return
|
|
}
|
|
tmpFile.Close()
|
|
|
|
zr, err := zip.OpenReader(tmpPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
|
return
|
|
}
|
|
defer zr.Close()
|
|
|
|
// 3. Extract and validate manifest
|
|
manifest, err := extractManifest(zr)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
newID, _ := manifest["id"].(string)
|
|
if newID != pkgID {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("manifest id %q does not match package %q", newID, pkgID)})
|
|
return
|
|
}
|
|
|
|
newType, _ := manifest["type"].(string)
|
|
if newType == "" {
|
|
newType = "surface"
|
|
}
|
|
if newType != existing.Type {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot change package type from %q to %q", existing.Type, newType)})
|
|
return
|
|
}
|
|
|
|
// 4. Version comparison — reject same or older
|
|
newVersion, _ := manifest["version"].(string)
|
|
if newVersion == "" {
|
|
newVersion = "0.0.0"
|
|
}
|
|
oldSemver, errOld := ParseSemver(existing.Version)
|
|
newSemver, errNew := ParseSemver(newVersion)
|
|
if errOld != nil || errNew != nil {
|
|
// If either version is unparseable, allow the update (legacy data)
|
|
if errNew != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version in manifest: " + errNew.Error()})
|
|
return
|
|
}
|
|
} else if newSemver.Compare(oldSemver) <= 0 {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": fmt.Sprintf("version %s is not newer than installed %s", newVersion, existing.Version),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 5. Unicode security scan
|
|
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(f.Name))
|
|
if !scanExts[ext] {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
findings := sandbox.ScanSource(string(data), f.Name)
|
|
if len(findings) > 0 {
|
|
if blocked, reason := sandbox.Verdict(findings); blocked {
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
|
"error": "extension_blocked",
|
|
"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Additive schema migration
|
|
var schemaChanges []string
|
|
if tables, ok := ParseDBTables(manifest); ok {
|
|
changes, err := MigrateExtTables(ctx, database.DB, database.IsPostgres(), h.stores, pkgID, tables, h.capabilities["pgvector"])
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "schema migration failed: " + err.Error()})
|
|
return
|
|
}
|
|
schemaChanges = changes
|
|
}
|
|
|
|
// 7. Settings merge — add new keys with defaults, preserve existing
|
|
mergedSettings := mergePackageSettings(existing, manifest)
|
|
if mergedSettings != nil {
|
|
if err := h.stores.Packages.SetPackageSettings(ctx, pkgID, mergedSettings); err != nil {
|
|
log.Printf("[packages] settings merge failed for %s: %v", pkgID, err)
|
|
}
|
|
}
|
|
|
|
// 8. Replace assets
|
|
if h.packagesDir != "" {
|
|
if err := extractPackageAssets(zr, h.packagesDir, pkgID); err != nil {
|
|
log.Printf("[packages] asset extraction failed for %s: %v", pkgID, err)
|
|
}
|
|
}
|
|
|
|
// 9. Update DB record
|
|
description, _ := manifest["description"].(string)
|
|
author, _ := manifest["author"].(string)
|
|
tier, _ := manifest["tier"].(string)
|
|
if tier == "" {
|
|
tier = "browser"
|
|
}
|
|
|
|
pkg := &store.PackageRegistration{
|
|
Title: manifest["title"].(string),
|
|
Type: newType,
|
|
Version: newVersion,
|
|
Description: description,
|
|
Author: author,
|
|
Tier: tier,
|
|
IsSystem: existing.IsSystem,
|
|
Enabled: existing.Enabled,
|
|
Manifest: manifest,
|
|
}
|
|
if err := h.stores.Packages.Update(ctx, pkgID, pkg); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package record"})
|
|
return
|
|
}
|
|
|
|
// 10. Re-sync permissions, triggers, dependencies
|
|
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
|
SyncManifestTriggers(ctx, h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
|
|
|
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
|
if h.stores.Dependencies != nil {
|
|
h.stores.Dependencies.DeleteAllForConsumer(ctx, pkgID)
|
|
}
|
|
for libID, vSpec := range deps {
|
|
lib, err := h.stores.Packages.Get(ctx, libID)
|
|
if err != nil || lib == nil {
|
|
log.Printf("[packages] update dependency %s not found for %s", libID, pkgID)
|
|
continue
|
|
}
|
|
versionSpec, _ := vSpec.(string)
|
|
if versionSpec == "" {
|
|
versionSpec = ">=0.0.0"
|
|
}
|
|
if h.stores.Dependencies != nil {
|
|
h.stores.Dependencies.Create(ctx, &models.ExtDependency{
|
|
ConsumerID: pkgID,
|
|
LibraryID: libID,
|
|
VersionSpec: versionSpec,
|
|
ResolvedVer: lib.Version,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 11. Re-install workflow definition if applicable
|
|
if newType == "workflow" {
|
|
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
|
|
log.Printf("[packages] workflow update failed for %s: %v", pkgID, err)
|
|
}
|
|
}
|
|
|
|
previousVersion := existing.Version
|
|
log.Printf("[packages] Updated %s from v%s to v%s", pkgID, previousVersion, newVersion)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": pkgID,
|
|
"version": newVersion,
|
|
"previous_version": previousVersion,
|
|
"changes": schemaChanges,
|
|
})
|
|
h.broadcastPackageChanged("updated", pkgID)
|
|
}
|
|
|
|
// mergePackageSettings merges existing settings with a new manifest's settings schema.
|
|
// New keys get their declared default value; existing keys are preserved.
|
|
func mergePackageSettings(existing *store.PackageRegistration, manifest map[string]any) json.RawMessage {
|
|
newSchema, _ := manifest["settings"].([]any)
|
|
if len(newSchema) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Parse existing settings
|
|
var current map[string]any
|
|
if existing.PackageSettings != nil {
|
|
if err := json.Unmarshal(existing.PackageSettings, ¤t); err != nil {
|
|
current = make(map[string]any)
|
|
}
|
|
}
|
|
if current == nil {
|
|
current = make(map[string]any)
|
|
}
|
|
|
|
changed := false
|
|
for _, entry := range newSchema {
|
|
e, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key, _ := e["key"].(string)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := current[key]; !exists {
|
|
current[key] = e["default"]
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if !changed {
|
|
return nil
|
|
}
|
|
|
|
data, _ := json.Marshal(current)
|
|
return data
|
|
}
|
|
|
|
// ── Package export ──────────────────────
|
|
|
|
// ExportPackage exports an installed package as a downloadable .pkg archive.
|
|
// GET /api/v1/admin/packages/:id/export
|
|
func (h *PackageHandler) ExportPackage(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
pkgID := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(ctx, pkgID)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
// Set response headers
|
|
filename := fmt.Sprintf("%s-%s.pkg", pkgID, pkg.Version)
|
|
c.Header("Content-Type", "application/zip")
|
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
|
|
zw := zip.NewWriter(c.Writer)
|
|
defer zw.Close()
|
|
|
|
// Write manifest.json from DB
|
|
manifestJSON, err := json.MarshalIndent(pkg.Manifest, "", " ")
|
|
if err != nil {
|
|
log.Printf("[packages] export: failed to marshal manifest for %s: %v", pkgID, err)
|
|
return
|
|
}
|
|
mw, err := zw.Create("manifest.json")
|
|
if err != nil {
|
|
return
|
|
}
|
|
mw.Write(manifestJSON)
|
|
|
|
// Walk packagesDir/{id}/ and add all asset files
|
|
if h.packagesDir == "" {
|
|
log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID)
|
|
c.Header("X-Export-Warning", "no-assets")
|
|
return
|
|
}
|
|
pkgDir := filepath.Join(h.packagesDir, pkgID)
|
|
if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) {
|
|
log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID)
|
|
c.Header("X-Export-Warning", "no-assets")
|
|
return
|
|
}
|
|
filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
relPath, err := filepath.Rel(pkgDir, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
relPath = filepath.ToSlash(relPath) // normalize to forward slashes in zip
|
|
|
|
fw, err := zw.Create(relPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
io.Copy(fw, f)
|
|
return nil
|
|
})
|
|
}
|