Add UpdatePackage and ExportPackage handlers
UpdatePackage (POST /packages/:id/update) validates semver bump, applies additive schema migration, merges settings (new keys with defaults, existing preserved), replaces assets, and re-syncs permissions/triggers. ExportPackage (GET /packages/:id/export) streams the installed package as a .pkg ZIP archive containing manifest.json and all assets. Completes the manual rollback story: export before update, re-install old .pkg if needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -897,3 +897,343 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
|||||||
"output": output,
|
"output": output,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Package update (v0.5.4) ──────────────────────
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (v0.5.4) ──────────────────────
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkgDir := filepath.Join(h.packagesDir, pkgID)
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user