package handlers // package_export.go — v0.30.0 CS3 // // Exports an installed package as a downloadable .pkg archive for // cross-instance sharing. Includes manifest, static assets, and // Starlark scripts. Does NOT include extension table data or secrets. import ( "archive/zip" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/store" ) // PackageExportHandler handles package export operations. type PackageExportHandler struct { stores store.Stores packagesDir string } // NewPackageExportHandler creates a new export handler. func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler { return &PackageExportHandler{stores: s, packagesDir: packagesDir} } // ExportPackage downloads an installed package as a .pkg archive. // GET /api/v1/admin/packages/:id/export func (h *PackageExportHandler) ExportPackage(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": "cannot export core packages"}) return } // Build manifest for export manifest := make(map[string]any, len(pkg.Manifest)) for k, v := range pkg.Manifest { // Skip internal fields that shouldn't be exported if k == "_script" { continue } manifest[k] = v } // Include package_settings as default_settings so importing instance // gets admin defaults (but not secrets) if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" { var ps map[string]any if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 { manifest["default_settings"] = ps } } // Set response headers filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version) c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) // Create zip writer directly to response zw := zip.NewWriter(c.Writer) defer zw.Close() // Write manifest.json manifestJSON, err := json.MarshalIndent(manifest, "", " ") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"}) return } mf, err := zw.Create("manifest.json") if err != nil { return } mf.Write(manifestJSON) // Include Starlark script as script.star if present if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" { sf, err := zw.Create("script.star") if err == nil { sf.Write([]byte(script)) } } // Walk packagesDir/{id}/ for static assets if h.packagesDir != "" { assetsDir := filepath.Join(h.packagesDir, id) if info, err := os.Stat(assetsDir); err == nil && info.IsDir() { filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } relPath, err := filepath.Rel(assetsDir, path) if err != nil { return nil } // Normalize path separators for zip relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/") f, err := zw.Create(relPath) if err != nil { return nil } src, err := os.Open(path) if err != nil { return nil } defer src.Close() io.Copy(f, src) return nil }) } } }