Feat v0.7.9 workflow independence (#63)
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s
All checks were successful
CI/CD / detect-changes (push) Successful in 20s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 25s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / test-go-pg (push) Successful in 3m0s
CI/CD / build-and-deploy (push) Successful in 1m26s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #63.
This commit is contained in:
@@ -213,66 +213,150 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
// 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
|
||||
//
|
||||
// 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) {
|
||||
var tmpPath string
|
||||
var cleanupTmp bool
|
||||
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
tmpPath = regFile.(string)
|
||||
// Don't remove — caller manages lifecycle
|
||||
} else {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate extension
|
||||
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
|
||||
}
|
||||
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
cleanupTmp = true
|
||||
|
||||
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
|
||||
}
|
||||
tmpFile.Close()
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Open as zip
|
||||
// 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: Check capabilities → mark dormant if unmet
|
||||
isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": mInfo.Title,
|
||||
"type": mInfo.Type,
|
||||
"version": mInfo.Version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
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
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Find and parse manifest.json
|
||||
var manifest map[string]any
|
||||
@@ -289,19 +373,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing manifest")
|
||||
}
|
||||
|
||||
// Scripts are loaded from disk at runtime — no _starlark_script injection.
|
||||
// Validate starlark entry point
|
||||
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
|
||||
@@ -316,13 +401,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
|
||||
return
|
||||
return nil, nil, nil, fmt.Errorf("missing entry point")
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode security gate — scan all scannable files for invisible/deceptive
|
||||
// characters before writing anything to the extension store.
|
||||
// Unicode security scan
|
||||
scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
@@ -344,116 +429,94 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
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
|
||||
return nil, nil, nil, fmt.Errorf("blocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate manifest structure (required fields, type, constraints)
|
||||
// Validate manifest structure
|
||||
mInfo, err := ValidateManifest(manifest)
|
||||
if err != nil {
|
||||
zr.Close()
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
pkgID := mInfo.ID
|
||||
title := mInfo.Title
|
||||
pkgType := mInfo.Type
|
||||
|
||||
// Package signing hook (schema reserved — no crypto verification yet)
|
||||
// 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", pkgID, 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)", pkgID)
|
||||
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
|
||||
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
|
||||
if h.packagesDir != "" {
|
||||
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)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
||||
}
|
||||
|
||||
// Use validated manifest fields for DB columns
|
||||
version := mInfo.Version
|
||||
description := mInfo.Description
|
||||
author := mInfo.Author
|
||||
tier := mInfo.Tier
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
|
||||
// Register in database via Seed (upsert — handles re-install)
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
||||
// 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
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Read back to get the preserved enabled state (Seed doesn't override it)
|
||||
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
preservedEnabled := true
|
||||
if existing != nil {
|
||||
preservedEnabled = existing.Enabled
|
||||
}
|
||||
|
||||
// Update fields that Seed doesn't set (type, version, author, etc.)
|
||||
pkg := &store.PackageRegistration{
|
||||
Title: title,
|
||||
Type: pkgType,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Tier: tier,
|
||||
Title: mInfo.Title,
|
||||
Type: mInfo.Type,
|
||||
Version: mInfo.Version,
|
||||
Description: mInfo.Description,
|
||||
Author: mInfo.Author,
|
||||
Tier: mInfo.Tier,
|
||||
IsSystem: false,
|
||||
Enabled: preservedEnabled,
|
||||
Manifest: manifest,
|
||||
@@ -464,17 +527,19 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
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); err != nil {
|
||||
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// This creates the permission rows and sets status to pending_review
|
||||
// if the package declares permissions.
|
||||
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
||||
|
||||
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
@@ -488,7 +553,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
||||
oldSchemaVersion, newSchemaVersion),
|
||||
})
|
||||
return
|
||||
return fmt.Errorf("downgrade")
|
||||
}
|
||||
if newSchemaVersion > oldSchemaVersion {
|
||||
if err := RunSchemaMigrations(
|
||||
@@ -500,89 +565,77 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "schema migration failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if pkgType == "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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Libraries must be installed first; consumers declare them.
|
||||
// If a dependency is missing but available as a bundled package, auto-install it.
|
||||
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
||||
// Clear stale dependency records from a previous install of the same consumer.
|
||||
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")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Re-fetch after install
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
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")); installErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||
})
|
||||
return installErr
|
||||
}
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
|
||||
return
|
||||
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
}
|
||||
}
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
}
|
||||
|
||||
if (pkgType == "surface" || pkgType == "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)
|
||||
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
|
||||
}
|
||||
|
||||
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||
// checkCapabilities marks a package dormant if it declares unmet `requires` entries.
|
||||
func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
|
||||
knownCaps := map[string]bool{}
|
||||
var unmetReqs []string
|
||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||
@@ -593,27 +646,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
isDormant := len(unmetReqs) > 0
|
||||
if isDormant {
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
if len(unmetReqs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
h.broadcastPackageChanged("installed", pkgID)
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
*preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
return true
|
||||
}
|
||||
|
||||
// extractableRelPath returns the relative path for a zip entry if it
|
||||
|
||||
Reference in New Issue
Block a user