Feat v0.8.2 capability negotiation (#69)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 30s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #69.
This commit is contained in:
2026-04-03 09:14:00 +00:00
committed by xcaliber
parent 435f972ded
commit 00ef970163
16 changed files with 593 additions and 96 deletions

View File

@@ -38,6 +38,7 @@ type PackageHandler struct {
sandbox *sandbox.Sandbox
runner *sandbox.Runner
hub *events.Hub
capabilities map[string]bool // detected environment capabilities
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -69,6 +70,12 @@ func (h *PackageHandler) SetHub(hub *events.Hub) {
h.hub = hub
}
// SetCapabilities stores the detected environment capabilities map.
// Used at install time to validate manifest capabilities.required.
func (h *PackageHandler) SetCapabilities(caps map[string]bool) {
h.capabilities = caps
}
// broadcastPackageChanged emits a package.changed event to all clients.
func (h *PackageHandler) broadcastPackageChanged(action, id string) {
if h.hub == nil {
@@ -286,8 +293,23 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// Phase 10: Check capabilities → mark dormant if unmet
isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
// Phase 10: Validate required capabilities
if caps, ok := ParseCapabilities(manifest); ok {
missing := CheckRequiredCapabilities(caps.Required, h.capabilities)
if len(missing) > 0 {
h.stores.Packages.Delete(c.Request.Context(), pkgID)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "missing required capabilities",
"missing": missing,
"help": capabilityHelpText(missing),
})
return
}
unmetOpt := CheckRequiredCapabilities(caps.Optional, h.capabilities)
if len(unmetOpt) > 0 {
log.Printf("[packages] %s: optional capabilities unavailable: %v", pkgID, unmetOpt)
}
}
resp := gin.H{
"id": pkgID,
@@ -297,9 +319,6 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
"source": pkgSource,
"enabled": preservedEnabled,
}
if isDormant {
resp["status"] = "dormant"
}
c.JSON(http.StatusOK, resp)
h.broadcastPackageChanged("installed", pkgID)
}
@@ -591,7 +610,7 @@ func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manif
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 {
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id"), h.capabilities); installErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
})
@@ -634,27 +653,6 @@ func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manif
return nil
}
// 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 {
for _, r := range reqs {
req, _ := r.(string)
if req != "" && !knownCaps[req] {
unmetReqs = append(unmetReqs, req)
}
}
}
if len(unmetReqs) == 0 {
return false
}
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
// belongs to an extractable directory (js/, css/, assets/, star/) or