This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/package_export.go
Jeffrey Smith 7d14e6a439 Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-18 12:06:55 +00:00

128 lines
3.3 KiB
Go

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
})
}
}
}