Feat v0.5.4 package updates #34
@@ -897,3 +897,343 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
||||
"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