Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -202,20 +202,20 @@ Depends on: v0.29.1.
|
|||||||
- [x] Schema creation on install, drop on uninstall
|
- [x] Schema creation on install, drop on uninstall
|
||||||
- [x] Server-side tool execution in completion handler (deferred from v0.29.1)
|
- [x] Server-side tool execution in completion handler (deferred from v0.29.1)
|
||||||
|
|
||||||
### v0.29.3 — Workflow Forms
|
### v0.29.3 — Workflow Forms ✅
|
||||||
|
|
||||||
`form_template` renders as real UI. LLM is optional for data collection.
|
`form_template` renders as real UI. LLM is optional for data collection.
|
||||||
|
|
||||||
Depends on: v0.29.2, v0.29.0 (Starlark validators).
|
Depends on: v0.29.2, v0.29.0 (Starlark validators).
|
||||||
|
|
||||||
- [ ] Typed `form_template` schema (`text`, `email`, `select`, `number`,
|
- [x] Typed `form_template` schema (`text`, `email`, `select`, `number`,
|
||||||
`date`, `textarea`, `checkbox`, `file`) with validation rules
|
`date`, `textarea`, `checkbox`, `file`) with validation rules
|
||||||
- [ ] Stage renders as form when `form_template` has typed fields
|
- [x] Stage renders as form when `form_template` has typed fields
|
||||||
- [ ] LLM-optional stages: form-only, form+chat, chat-only
|
- [x] LLM-optional stages: form-only, form+chat, chat-only
|
||||||
- [ ] Starlark `validate` / `on_submit` hooks
|
- [x] Starlark `validate` / `on_submit` hooks
|
||||||
- [ ] Visitor form entry (branded page, no chat widget)
|
- [x] Visitor form entry (branded page, no chat widget)
|
||||||
- [ ] Form builder in workflow admin (visual field editor)
|
- [x] Form builder in workflow admin (visual field editor)
|
||||||
- [ ] Cross-visitor isolation E2E test (deferred from v0.28.4)
|
- [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
|
||||||
|
|
||||||
### v0.30.0 — Package Lifecycle
|
### v0.30.0 — Package Lifecycle
|
||||||
|
|
||||||
@@ -223,11 +223,11 @@ Lifecycle sophistication for `.pkg` format.
|
|||||||
|
|
||||||
Depends on: v0.29.2.
|
Depends on: v0.29.2.
|
||||||
|
|
||||||
- [ ] Schema versioning + migrations in manifest
|
- [x] Schema versioning + migrations in manifest
|
||||||
- [ ] Settings extension point (packages declare settings sections)
|
- [x] Settings extension point (packages declare settings sections)
|
||||||
- [ ] Export/import format for cross-instance sharing
|
- [x] Export/import format for cross-instance sharing
|
||||||
- [ ] Package marketplace (discovery, not hosting)
|
- [x] Package marketplace (discovery, not hosting)
|
||||||
- [ ] User-installable packages (RBAC-gated, team/personal scope)
|
- [x] User-installable packages (RBAC-gated, team/personal scope)
|
||||||
|
|
||||||
### v0.30.1 — SDK Adoption
|
### v0.30.1 — SDK Adoption
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
package_settings JSONB NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
CHECK (source IN ('core', 'builtin', 'extension')),
|
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ CREATE TABLE IF NOT EXISTS packages (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||||
source TEXT NOT NULL DEFAULT 'core'
|
source TEXT NOT NULL DEFAULT 'core'
|
||||||
CHECK (source IN ('core', 'builtin', 'extension')),
|
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||||
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|||||||
127
server/handlers/package_export.go
Normal file
127
server/handlers/package_export.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
server/handlers/package_migrations.go
Normal file
160
server/handlers/package_migrations.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// package_migrations.go — v0.30.0 CS1
|
||||||
|
//
|
||||||
|
// Schema migration engine for extension packages. When a package declares
|
||||||
|
// schema_version and migrations in its manifest, this engine runs Starlark
|
||||||
|
// migration scripts on install (fresh or upgrade).
|
||||||
|
//
|
||||||
|
// Manifest format:
|
||||||
|
// {
|
||||||
|
// "schema_version": 2,
|
||||||
|
// "migrations": {
|
||||||
|
// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})",
|
||||||
|
// "2": "def migrate(db):\n ..."
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// On fresh install: runs migrations 1..schema_version.
|
||||||
|
// On upgrade: runs migrations (current+1)..new_schema_version.
|
||||||
|
// On downgrade: rejected (409).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseSchemaVersion extracts the "schema_version" integer from a manifest.
|
||||||
|
// Returns 0 if not present or not a number.
|
||||||
|
func ParseSchemaVersion(manifest map[string]any) int {
|
||||||
|
raw, ok := manifest["schema_version"]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMigrations extracts the "migrations" map from a manifest.
|
||||||
|
// Keys are string integers ("1", "2", ...), values are Starlark source code.
|
||||||
|
func ParseMigrations(manifest map[string]any) map[int]string {
|
||||||
|
raw, ok := manifest["migrations"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
migrationsRaw, ok := raw.(map[string]any)
|
||||||
|
if !ok || len(migrationsRaw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[int]string, len(migrationsRaw))
|
||||||
|
for key, val := range migrationsRaw {
|
||||||
|
version, err := strconv.Atoi(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[pkg-migrate] skipping non-integer migration key %q", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
script, ok := val.(string)
|
||||||
|
if !ok || script == "" {
|
||||||
|
log.Printf("[pkg-migrate] skipping empty migration for version %d", version)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[version] = script
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1
|
||||||
|
// to toVersion, updating schema_version in the store after each successful step.
|
||||||
|
//
|
||||||
|
// The sandbox runs each migration script with a db module at write level,
|
||||||
|
// scoped to the package's namespace. This bypasses the permission system
|
||||||
|
// because migrations are admin-initiated.
|
||||||
|
//
|
||||||
|
// On error, returns immediately — partial migration state is tracked via
|
||||||
|
// the per-step schema_version update.
|
||||||
|
func RunSchemaMigrations(
|
||||||
|
ctx context.Context,
|
||||||
|
sb *sandbox.Sandbox,
|
||||||
|
stores store.Stores,
|
||||||
|
db *sql.DB,
|
||||||
|
isPostgres bool,
|
||||||
|
packageID string,
|
||||||
|
manifest map[string]any,
|
||||||
|
fromVersion int,
|
||||||
|
toVersion int,
|
||||||
|
) error {
|
||||||
|
if fromVersion >= toVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := ParseMigrations(manifest)
|
||||||
|
if migrations == nil && toVersion > 0 {
|
||||||
|
// No migration scripts but schema_version declared — just set the version
|
||||||
|
return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
for step := fromVersion + 1; step <= toVersion; step++ {
|
||||||
|
script, ok := migrations[step]
|
||||||
|
if !ok {
|
||||||
|
// No script for this step — just bump the version
|
||||||
|
log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step)
|
||||||
|
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
|
||||||
|
return fmt.Errorf("set schema_version to %d: %w", step, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[pkg-migrate] %s: running migration %d", packageID, step)
|
||||||
|
|
||||||
|
// Build a db module at write level for the migration
|
||||||
|
dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{
|
||||||
|
PackageID: packageID,
|
||||||
|
CanWrite: true,
|
||||||
|
DB: db,
|
||||||
|
IsPostgres: isPostgres,
|
||||||
|
})
|
||||||
|
|
||||||
|
modules := map[string]starlark.Value{
|
||||||
|
"db": dbModule,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the migration script
|
||||||
|
result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call migrate(db) if defined
|
||||||
|
if migrateFn, ok := result.Globals["migrate"]; ok {
|
||||||
|
if callable, ok := migrateFn.(starlark.Callable); ok {
|
||||||
|
_, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this step as complete
|
||||||
|
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
|
||||||
|
return fmt.Errorf("set schema_version to %d: %w", step, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
260
server/handlers/package_registry.go
Normal file
260
server/handlers/package_registry.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// package_registry.go — v0.30.0 CS4
|
||||||
|
//
|
||||||
|
// Package registry (marketplace discovery). Admin browses available
|
||||||
|
// packages from an external JSON registry and installs them by URL.
|
||||||
|
//
|
||||||
|
// Registry format (external JSON file):
|
||||||
|
// {
|
||||||
|
// "packages": [
|
||||||
|
// {"id": "...", "title": "...", "version": "...", "description": "...",
|
||||||
|
// "author": "...", "type": "...", "tier": "...",
|
||||||
|
// "download_url": "https://..."}
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Registry URL is stored in global_config under key "package_registry".
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryEntry represents a single package in the registry.
|
||||||
|
type RegistryEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryResponse is the top-level registry JSON structure.
|
||||||
|
type RegistryResponse struct {
|
||||||
|
Packages []RegistryEntry `json:"packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryHandler handles registry browse and install operations.
|
||||||
|
type RegistryHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
packagesDir string
|
||||||
|
pkgHandler *PackageHandler
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cacheData *RegistryResponse
|
||||||
|
cacheURL string
|
||||||
|
cacheUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
registryCacheTTL = 5 * time.Minute
|
||||||
|
registryFetchTimeout = 10 * time.Second
|
||||||
|
registryMaxSize = 50 * 1024 * 1024 // 50MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRegistryHandler creates a new registry handler.
|
||||||
|
func NewRegistryHandler(s store.Stores, packagesDir string, pkgHandler *PackageHandler) *RegistryHandler {
|
||||||
|
return &RegistryHandler{
|
||||||
|
stores: s,
|
||||||
|
packagesDir: packagesDir,
|
||||||
|
pkgHandler: pkgHandler,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: registryFetchTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegistryURL reads the registry URL from global_config.
|
||||||
|
func (h *RegistryHandler) getRegistryURL(c *gin.Context) string {
|
||||||
|
if h.stores.GlobalConfig == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), "package_registry")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
url, _ := val["url"].(string)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseRegistry fetches and returns the registry package list.
|
||||||
|
// GET /api/v1/admin/packages/registry
|
||||||
|
func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
|
||||||
|
url := h.getRegistryURL(c)
|
||||||
|
if url == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"packages": []RegistryEntry{},
|
||||||
|
"registry_url": "",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
h.cacheMu.RLock()
|
||||||
|
if h.cacheData != nil && h.cacheURL == url && time.Now().Before(h.cacheUntil) {
|
||||||
|
data := h.cacheData
|
||||||
|
h.cacheMu.RUnlock()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"packages": data.Packages,
|
||||||
|
"registry_url": url,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch registry
|
||||||
|
data, err := h.fetchRegistry(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[registry] fetch failed: %v", err)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch registry: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
h.cacheMu.Lock()
|
||||||
|
h.cacheData = data
|
||||||
|
h.cacheURL = url
|
||||||
|
h.cacheUntil = time.Now().Add(registryCacheTTL)
|
||||||
|
h.cacheMu.Unlock()
|
||||||
|
|
||||||
|
// Mark installed packages
|
||||||
|
installed := make(map[string]bool)
|
||||||
|
pkgs, _ := h.stores.Packages.List(c.Request.Context())
|
||||||
|
for _, p := range pkgs {
|
||||||
|
installed[p.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
type enrichedEntry struct {
|
||||||
|
RegistryEntry
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]enrichedEntry, len(data.Packages))
|
||||||
|
for i, p := range data.Packages {
|
||||||
|
entries[i] = enrichedEntry{
|
||||||
|
RegistryEntry: p,
|
||||||
|
Installed: installed[p.ID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"packages": entries,
|
||||||
|
"registry_url": url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallFromRegistry downloads a .pkg from a URL and installs it.
|
||||||
|
// POST /api/v1/admin/packages/registry/install
|
||||||
|
func (h *RegistryHandler) InstallFromRegistry(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.DownloadURL == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: HTTPS only
|
||||||
|
if !strings.HasPrefix(body.DownloadURL, "https://") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url must use HTTPS"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the package
|
||||||
|
resp, err := h.client.Get(body.DownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"error": fmt.Sprintf("registry returned HTTP %d", resp.StatusCode),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file with size limit
|
||||||
|
tmpFile, err := os.CreateTemp("", "registry-pkg-*.pkg")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
limited := io.LimitReader(resp.Body, registryMaxSize)
|
||||||
|
n, err := io.Copy(tmpFile, limited)
|
||||||
|
tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[registry] downloaded %d bytes from %s", n, body.DownloadURL)
|
||||||
|
|
||||||
|
// Open and re-serve through the package install handler by creating
|
||||||
|
// a synthetic multipart request. Instead, we directly open the file
|
||||||
|
// and call the shared install logic.
|
||||||
|
file, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read downloaded package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Use the existing install flow by setting the file on the request
|
||||||
|
// We'll create a helper that accepts an io.ReadSeeker
|
||||||
|
c.Set("_registry_file", tmpPath)
|
||||||
|
c.Set("_registry_source", "registry")
|
||||||
|
h.pkgHandler.InstallPackage(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRegistry downloads and parses a registry JSON file.
|
||||||
|
func (h *RegistryHandler) fetchRegistry(url string) (*RegistryResponse, error) {
|
||||||
|
if !strings.HasPrefix(url, "https://") {
|
||||||
|
return nil, fmt.Errorf("registry URL must use HTTPS")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
limited := io.LimitReader(resp.Body, 10*1024*1024) // 10MB for registry JSON
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var reg RegistryResponse
|
||||||
|
if err := json.Unmarshal(data, ®); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid registry JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ®, nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
|||||||
type PackageHandler struct {
|
type PackageHandler struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
packagesDir string // e.g. /data/packages — where archives are extracted
|
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||||
|
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||||
@@ -36,6 +39,11 @@ func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
|||||||
return &PackageHandler{stores: s, packagesDir: dir}
|
return &PackageHandler{stores: s, packagesDir: dir}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
|
||||||
|
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
||||||
|
h.sandbox = sb
|
||||||
|
}
|
||||||
|
|
||||||
// ListPackages returns all registered packages.
|
// ListPackages returns all registered packages.
|
||||||
// GET /api/v1/admin/packages
|
// GET /api/v1/admin/packages
|
||||||
// GET /api/v1/admin/surfaces (alias)
|
// GET /api/v1/admin/surfaces (alias)
|
||||||
@@ -142,7 +150,19 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
|||||||
// InstallPackage uploads and installs a .pkg/.surface archive.
|
// InstallPackage uploads and installs a .pkg/.surface archive.
|
||||||
// POST /api/v1/admin/packages/install
|
// POST /api/v1/admin/packages/install
|
||||||
// POST /api/v1/admin/surfaces/install (alias)
|
// POST /api/v1/admin/surfaces/install (alias)
|
||||||
|
//
|
||||||
|
// 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
|
||||||
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||||
|
var tmpPath string
|
||||||
|
var cleanupTmp bool
|
||||||
|
|
||||||
|
// v0.30.0: Check for pre-downloaded file from registry install
|
||||||
|
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")
|
file, header, err := c.Request.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||||
@@ -171,15 +191,20 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath = tmpFile.Name()
|
||||||
defer os.Remove(tmpPath)
|
cleanupTmp = true
|
||||||
|
|
||||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
}
|
||||||
|
if cleanupTmp {
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Open as zip
|
// Open as zip
|
||||||
zr, err := zip.OpenReader(tmpPath)
|
zr, err := zip.OpenReader(tmpPath)
|
||||||
@@ -332,8 +357,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
|
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
// v0.30.0: use registry source if set by registry install handler
|
||||||
|
pkgSource := "extension"
|
||||||
|
if src, ok := c.Get("_registry_source"); ok {
|
||||||
|
pkgSource = src.(string)
|
||||||
|
}
|
||||||
|
|
||||||
// Register in database via Seed (upsert — handles re-install)
|
// Register in database via Seed (upsert — handles re-install)
|
||||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
|
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -371,12 +402,41 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.30.0: Run schema migrations if declared.
|
||||||
|
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||||
|
if newSchemaVersion > 0 && h.sandbox != nil {
|
||||||
|
oldSchemaVersion := 0
|
||||||
|
if existing != nil {
|
||||||
|
oldSchemaVersion = existing.SchemaVersion
|
||||||
|
}
|
||||||
|
if newSchemaVersion < oldSchemaVersion {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
||||||
|
oldSchemaVersion, newSchemaVersion),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newSchemaVersion > oldSchemaVersion {
|
||||||
|
if err := RunSchemaMigrations(
|
||||||
|
c.Request.Context(), h.sandbox, h.stores,
|
||||||
|
database.DB, database.IsPostgres(),
|
||||||
|
pkgID, manifest, oldSchemaVersion, newSchemaVersion,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "schema migration failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"type": pkgType,
|
"type": pkgType,
|
||||||
"version": version,
|
"version": version,
|
||||||
"source": "extension",
|
"source": pkgSource,
|
||||||
"enabled": preservedEnabled,
|
"enabled": preservedEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -408,6 +468,100 @@ func extractableRelPath(name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Package settings (v0.30.0 CS2) ──────────────
|
||||||
|
|
||||||
|
// GetPackageSettings returns the settings schema from the manifest merged
|
||||||
|
// with current admin-configured values.
|
||||||
|
// GET /api/v1/admin/packages/:id/settings
|
||||||
|
func (h *PackageHandler) GetPackageSettings(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract settings schema from manifest
|
||||||
|
schema, _ := pkg.Manifest["settings"].([]any)
|
||||||
|
|
||||||
|
// Get current values
|
||||||
|
current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
current = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"schema": schema,
|
||||||
|
"values": json.RawMessage(current),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePackageSettings validates and stores admin-configured package settings.
|
||||||
|
// PUT /api/v1/admin/packages/:id/settings
|
||||||
|
func (h *PackageHandler) UpdatePackageSettings(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate keys against declared schema
|
||||||
|
schemaArr, _ := pkg.Manifest["settings"].([]any)
|
||||||
|
validKeys := make(map[string]map[string]any, len(schemaArr))
|
||||||
|
for _, s := range schemaArr {
|
||||||
|
if entry, ok := s.(map[string]any); ok {
|
||||||
|
if key, ok := entry["key"].(string); ok {
|
||||||
|
validKeys[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only declared keys and type-check
|
||||||
|
filtered := make(map[string]any, len(body))
|
||||||
|
for k, v := range body {
|
||||||
|
schemaDef, ok := validKeys[k]
|
||||||
|
if !ok {
|
||||||
|
continue // skip undeclared keys
|
||||||
|
}
|
||||||
|
settingType, _ := schemaDef["type"].(string)
|
||||||
|
if validateSettingType(v, settingType) {
|
||||||
|
filtered[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJSON, _ := json.Marshal(filtered)
|
||||||
|
if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSettingType checks if a value matches the expected setting type.
|
||||||
|
func validateSettingType(v any, settingType string) bool {
|
||||||
|
switch settingType {
|
||||||
|
case "string", "select":
|
||||||
|
_, ok := v.(string)
|
||||||
|
return ok
|
||||||
|
case "number":
|
||||||
|
_, ok := v.(float64)
|
||||||
|
return ok
|
||||||
|
case "boolean":
|
||||||
|
_, ok := v.(bool)
|
||||||
|
return ok
|
||||||
|
default:
|
||||||
|
return true // unknown types pass through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
||||||
// GET /api/v1/surfaces
|
// GET /api/v1/surfaces
|
||||||
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
||||||
|
|||||||
353
server/handlers/user_packages.go
Normal file
353
server/handlers/user_packages.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// user_packages.go — v0.30.0 CS5
|
||||||
|
//
|
||||||
|
// Non-admin package management. Users install personal-scoped packages;
|
||||||
|
// team admins install team-scoped packages.
|
||||||
|
//
|
||||||
|
// Restrictions for non-global scope:
|
||||||
|
// - Tier: browser only (no starlark/sidecar)
|
||||||
|
// - Permissions: only notifications.send allowed
|
||||||
|
// - Prevents privilege escalation through user-installed packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedNonGlobalPermissions is the set of permissions that non-admin
|
||||||
|
// (team/personal scope) packages may declare.
|
||||||
|
var allowedNonGlobalPermissions = map[string]bool{
|
||||||
|
"notifications.send": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPackageHandler manages user and team scoped package operations.
|
||||||
|
type UserPackageHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
packagesDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserPackageHandler creates a new user package handler.
|
||||||
|
func NewUserPackageHandler(s store.Stores, packagesDir string) *UserPackageHandler {
|
||||||
|
return &UserPackageHandler{stores: s, packagesDir: packagesDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVisiblePackages returns packages visible to the current user
|
||||||
|
// (global + user's teams + personal).
|
||||||
|
// GET /api/v1/packages
|
||||||
|
func (h *UserPackageHandler) ListVisiblePackages(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, err := h.stores.Packages.ListVisiblePackages(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkgs == nil {
|
||||||
|
pkgs = []store.PackageRegistration{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": pkgs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallPersonalPackage installs a package with personal scope.
|
||||||
|
// POST /api/v1/packages/install
|
||||||
|
func (h *UserPackageHandler) InstallPersonalPackage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.installScoped(c, "personal", "", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallTeamPackage installs a package with team scope.
|
||||||
|
// POST /api/v1/teams/:teamId/packages/install
|
||||||
|
func (h *UserPackageHandler) InstallTeamPackage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
if userID == "" || teamID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing user or team context"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.installScoped(c, "team", teamID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePersonalPackage deletes a personal-scoped package owned by the user.
|
||||||
|
// DELETE /api/v1/packages/:id
|
||||||
|
func (h *UserPackageHandler) DeletePersonalPackage(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
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.Scope != "personal" || pkg.InstalledBy == nil || *pkg.InstalledBy != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own personal packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up assets
|
||||||
|
if h.packagesDir != "" {
|
||||||
|
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTeamPackage deletes a team-scoped package.
|
||||||
|
// DELETE /api/v1/teams/:teamId/packages/:id
|
||||||
|
func (h *UserPackageHandler) DeleteTeamPackage(c *gin.Context) {
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
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.Scope != "team" || pkg.TeamID == nil || *pkg.TeamID != teamID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.packagesDir != "" {
|
||||||
|
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// installScoped handles the common install flow for team/personal packages.
|
||||||
|
func (h *UserPackageHandler) installScoped(c *gin.Context, scope, teamID, userID string) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read into temp file
|
||||||
|
tmpFile, err := os.CreateTemp("", "userpkg-*.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()
|
||||||
|
|
||||||
|
// Open as zip and extract manifest
|
||||||
|
zr, err := zip.OpenReader(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
var manifest map[string]any
|
||||||
|
for _, f := range zr.File {
|
||||||
|
name := filepath.Base(f.Name)
|
||||||
|
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
json.Unmarshal(data, &manifest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgID, _ := manifest["id"].(string)
|
||||||
|
title, _ := manifest["title"].(string)
|
||||||
|
if pkgID == "" || title == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validPackageID.MatchString(pkgID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce tier restriction: browser only for non-global
|
||||||
|
tier, _ := manifest["tier"].(string)
|
||||||
|
if tier == "" {
|
||||||
|
tier = "browser"
|
||||||
|
}
|
||||||
|
if tier != "browser" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "only browser-tier packages can be installed at team/personal scope",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce permission restriction
|
||||||
|
if permsRaw, ok := manifest["permissions"].([]any); ok {
|
||||||
|
for _, p := range permsRaw {
|
||||||
|
if perm, ok := p.(string); ok {
|
||||||
|
if !allowedNonGlobalPermissions[perm] {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "permission " + perm + " is not allowed for " + scope + "-scoped packages",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
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"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract static assets
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
rc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
version, _ := manifest["version"].(string)
|
||||||
|
if version == "" {
|
||||||
|
version = "0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgType, _ := manifest["type"].(string)
|
||||||
|
if pkgType == "" {
|
||||||
|
pkgType = "surface"
|
||||||
|
}
|
||||||
|
|
||||||
|
description, _ := manifest["description"].(string)
|
||||||
|
author, _ := manifest["author"].(string)
|
||||||
|
|
||||||
|
// Create the package with scope
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: pkgID,
|
||||||
|
Title: title,
|
||||||
|
Type: pkgType,
|
||||||
|
Version: version,
|
||||||
|
Description: description,
|
||||||
|
Author: author,
|
||||||
|
Tier: tier,
|
||||||
|
IsSystem: false,
|
||||||
|
Scope: scope,
|
||||||
|
Enabled: true,
|
||||||
|
Status: "active",
|
||||||
|
Source: "extension",
|
||||||
|
Manifest: manifest,
|
||||||
|
}
|
||||||
|
|
||||||
|
if teamID != "" {
|
||||||
|
pkg.TeamID = &teamID
|
||||||
|
}
|
||||||
|
pkg.InstalledBy = &userID
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// Update existing
|
||||||
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] installed %s-scoped package %s by user %s", scope, pkgID, userID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": pkgID,
|
||||||
|
"title": title,
|
||||||
|
"type": pkgType,
|
||||||
|
"version": version,
|
||||||
|
"scope": scope,
|
||||||
|
"source": "extension",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -707,6 +707,16 @@ func main() {
|
|||||||
pkgH := handlers.NewPackageHandler(stores)
|
pkgH := handlers.NewPackageHandler(stores)
|
||||||
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||||
|
|
||||||
|
// User package management (v0.30.0)
|
||||||
|
userPkgDir := ""
|
||||||
|
if cfg.StoragePath != "" {
|
||||||
|
userPkgDir = cfg.StoragePath + "/packages"
|
||||||
|
}
|
||||||
|
userPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
|
||||||
|
protected.GET("/packages", userPkgH.ListVisiblePackages)
|
||||||
|
protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
|
||||||
|
protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage)
|
||||||
|
|
||||||
// Summarize & Continue (backed by compaction service)
|
// Summarize & Continue (backed by compaction service)
|
||||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||||
summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
|
summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
|
||||||
@@ -934,6 +944,11 @@ func main() {
|
|||||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||||
|
|
||||||
|
// Team package management (v0.30.0)
|
||||||
|
teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
|
||||||
|
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
||||||
|
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
|
||||||
|
|
||||||
// Team audit log (team admins only — RequireTeamAdmin on group)
|
// Team audit log (team admins only — RequireTeamAdmin on group)
|
||||||
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||||
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||||
@@ -1185,12 +1200,25 @@ func main() {
|
|||||||
packagesDir = cfg.StoragePath + "/packages"
|
packagesDir = cfg.StoragePath + "/packages"
|
||||||
}
|
}
|
||||||
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
||||||
|
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
|
||||||
|
|
||||||
|
// Package registry — must be registered before /packages/:id (v0.30.0)
|
||||||
|
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
||||||
|
admin.GET("/packages/registry", registryH.BrowseRegistry)
|
||||||
|
admin.POST("/packages/registry/install", registryH.InstallFromRegistry)
|
||||||
|
|
||||||
admin.GET("/packages", pkgAdm.ListPackages)
|
admin.GET("/packages", pkgAdm.ListPackages)
|
||||||
admin.GET("/packages/:id", pkgAdm.GetPackage)
|
admin.GET("/packages/:id", pkgAdm.GetPackage)
|
||||||
admin.POST("/packages/install", pkgAdm.InstallPackage)
|
admin.POST("/packages/install", pkgAdm.InstallPackage)
|
||||||
admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
|
admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
|
||||||
admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
|
admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
|
||||||
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
|
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
|
||||||
|
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0
|
||||||
|
admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0
|
||||||
|
|
||||||
|
// Package export (v0.30.0)
|
||||||
|
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
||||||
|
admin.GET("/packages/:id/export", pkgExport.ExportPackage)
|
||||||
|
|
||||||
// Surface aliases (backward compat — same handlers)
|
// Surface aliases (backward compat — same handlers)
|
||||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||||
|
|||||||
@@ -201,5 +201,13 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.30.0: settings module — always injected, no permission required.
|
||||||
|
// A package reads its own admin + user settings.
|
||||||
|
userID := ""
|
||||||
|
if rc != nil {
|
||||||
|
userID = rc.UserID
|
||||||
|
}
|
||||||
|
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID)
|
||||||
|
|
||||||
return modules, nil
|
return modules, nil
|
||||||
}
|
}
|
||||||
|
|||||||
74
server/sandbox/settings_module.go
Normal file
74
server/sandbox/settings_module.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
// settings_module.go — v0.30.0 CS2
|
||||||
|
//
|
||||||
|
// The settings module lets extensions read their admin-configured and
|
||||||
|
// per-user settings. Always injected (no permission required — a package
|
||||||
|
// reads its own settings).
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// val = settings.get("key") # returns string, number, bool, or None
|
||||||
|
// val = settings.get("key", "default") # returns default if key not set
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildSettingsModule creates the "settings" Starlark module for a package.
|
||||||
|
// It reads from package_settings (admin-configured) merged with per-user
|
||||||
|
// settings when a userID is provided. Package-level settings are the base;
|
||||||
|
// user-level settings override.
|
||||||
|
func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID string, userID string) *starlarkstruct.Module {
|
||||||
|
return MakeModule("settings", starlark.StringDict{
|
||||||
|
"get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsGet(ctx context.Context, stores store.Stores, packageID string, userID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var key string
|
||||||
|
var defaultVal starlark.Value = starlark.None
|
||||||
|
if err := starlark.UnpackPositionalArgs("settings.get", args, kwargs, 1, &key, &defaultVal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load package-level settings
|
||||||
|
merged := make(map[string]any)
|
||||||
|
if stores.Packages != nil {
|
||||||
|
pkgSettings, err := stores.Packages.GetPackageSettings(ctx, packageID)
|
||||||
|
if err == nil && len(pkgSettings) > 0 {
|
||||||
|
json.Unmarshal([]byte(pkgSettings), &merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay user-level settings if a user context is available
|
||||||
|
if userID != "" && stores.Packages != nil {
|
||||||
|
userSettings, err := stores.Packages.GetUserSettings(ctx, packageID, userID)
|
||||||
|
if err == nil && userSettings != nil && len(userSettings.Settings) > 0 {
|
||||||
|
var userMap map[string]any
|
||||||
|
if json.Unmarshal([]byte(userSettings.Settings), &userMap) == nil {
|
||||||
|
for k, v := range userMap {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := merged[key]
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sv, err := goToStarlark(val)
|
||||||
|
if err != nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
return sv, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,23 @@ type PackageStore interface {
|
|||||||
|
|
||||||
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
// DeleteUserSettings removes per-user settings, reverting to defaults.
|
||||||
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
|
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
|
||||||
|
|
||||||
|
// ── Scoped visibility (v0.30.0) ────────────────
|
||||||
|
|
||||||
|
// ListVisiblePackages returns packages visible to the given user:
|
||||||
|
// global packages + team packages for user's teams + personal packages.
|
||||||
|
ListVisiblePackages(ctx context.Context, userID string) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// ── Package lifecycle (v0.30.0) ─────────────────
|
||||||
|
|
||||||
|
// SetSchemaVersion updates the current schema version for a package.
|
||||||
|
SetSchemaVersion(ctx context.Context, id string, version int) error
|
||||||
|
|
||||||
|
// GetPackageSettings returns the admin-configured package-level settings.
|
||||||
|
GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error)
|
||||||
|
|
||||||
|
// SetPackageSettings stores admin-configured package-level settings.
|
||||||
|
SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// PackageRegistration is a row from the packages table.
|
// PackageRegistration is a row from the packages table.
|
||||||
@@ -84,6 +101,8 @@ type PackageRegistration struct {
|
|||||||
Manifest map[string]any `json:"manifest" db:"manifest"`
|
Manifest map[string]any `json:"manifest" db:"manifest"`
|
||||||
Enabled bool `json:"enabled" db:"enabled"`
|
Enabled bool `json:"enabled" db:"enabled"`
|
||||||
Status string `json:"status" db:"status"`
|
Status string `json:"status" db:"status"`
|
||||||
|
SchemaVersion int `json:"schema_version" db:"schema_version"`
|
||||||
|
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
|
||||||
Source string `json:"source" db:"source"`
|
Source string `json:"source" db:"source"`
|
||||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||||
|
|||||||
@@ -106,13 +106,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
}
|
}
|
||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status, source)
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
schema_version, package_settings, source)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||||
RETURNING installed_at, updated_at`,
|
RETURNING installed_at, updated_at`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status, pkg.Source,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
|
pkg.Source,
|
||||||
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +157,13 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
WHERE p.enabled = true
|
WHERE p.enabled = true
|
||||||
AND p.type IN ('extension', 'full')
|
AND p.type IN ('extension', 'full')
|
||||||
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
|
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
|
||||||
|
AND (
|
||||||
|
p.scope = 'global'
|
||||||
|
OR (p.scope = 'personal' AND p.installed_by = $1)
|
||||||
|
OR (p.scope = 'team' AND p.team_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = $1
|
||||||
|
))
|
||||||
|
)
|
||||||
ORDER BY p.title`, userID)
|
ORDER BY p.title`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -165,6 +175,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
|
var pkgSettings []byte
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
var userSettings []byte
|
var userSettings []byte
|
||||||
|
|
||||||
@@ -172,8 +183,9 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||||
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
|
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &up.Enabled, &up.Status, &up.Source,
|
&manifestJSON, &up.Enabled, &up.Status,
|
||||||
&up.InstalledAt, &up.UpdatedAt,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
|
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -181,6 +193,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal(manifestJSON, &up.Manifest)
|
json.Unmarshal(manifestJSON, &up.Manifest)
|
||||||
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
up.UserEnabled = &userEnabled.Bool
|
up.UserEnabled = &userEnabled.Bool
|
||||||
}
|
}
|
||||||
@@ -235,18 +248,22 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str
|
|||||||
// so column additions don't silently break positional Scan().
|
// so column additions don't silently break positional Scan().
|
||||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at`
|
p.manifest, p.enabled, p.status,
|
||||||
|
p.schema_version, p.package_settings,
|
||||||
|
p.source, p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
|
var pkgSettings []byte
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
|
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -257,6 +274,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,23 +290,83 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
|
var pkgSettings []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
|
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scoped visibility (v0.30.0) ──────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListVisiblePackages(ctx context.Context, userID string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.enabled = true
|
||||||
|
AND (
|
||||||
|
p.scope = 'global'
|
||||||
|
OR (p.scope = 'personal' AND p.installed_by = $1)
|
||||||
|
OR (p.scope = 'team' AND p.team_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = $1
|
||||||
|
))
|
||||||
|
)
|
||||||
|
ORDER BY p.source, p.title`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Package lifecycle (v0.30.0) ──────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) SetSchemaVersion(ctx context.Context, id string, version int) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET schema_version = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
id, version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) {
|
||||||
|
var settings []byte
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT package_settings FROM packages WHERE id = $1`, id).Scan(&settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(settings), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET package_settings = $2, updated_at = NOW() WHERE id = $1`,
|
||||||
|
id, []byte(settings))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -296,3 +374,11 @@ func nullStrPtr(s *string) sql.NullString {
|
|||||||
}
|
}
|
||||||
return sql.NullString{String: *s, Valid: true}
|
return sql.NullString{String: *s, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultJSON returns the raw message or '{}' if nil/empty.
|
||||||
|
func defaultJSON(raw json.RawMessage) []byte {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return []byte("{}")
|
||||||
|
}
|
||||||
|
return []byte(raw)
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,13 +111,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
}
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status, source,
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
|
schema_version, package_settings, source,
|
||||||
installed_at, updated_at)
|
installed_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status, pkg.Source,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
|
pkg.Source,
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,7 +168,14 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
WHERE p.enabled = 1
|
WHERE p.enabled = 1
|
||||||
AND p.type IN ('extension', 'full')
|
AND p.type IN ('extension', 'full')
|
||||||
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1)
|
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1)
|
||||||
ORDER BY p.title`, userID)
|
AND (
|
||||||
|
p.scope = 'global'
|
||||||
|
OR (p.scope = 'personal' AND p.installed_by = ?)
|
||||||
|
OR (p.scope = 'team' AND p.team_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = ?
|
||||||
|
))
|
||||||
|
)
|
||||||
|
ORDER BY p.title`, userID, userID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -176,6 +186,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
@@ -185,8 +196,9 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
|
||||||
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
|
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &up.Status, &up.Source,
|
&manifestJSON, &enabledInt, &up.Status,
|
||||||
&up.InstalledAt, &up.UpdatedAt,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
|
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -196,6 +208,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
||||||
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
up.UserEnabled = &userEnabled.Bool
|
up.UserEnabled = &userEnabled.Bool
|
||||||
}
|
}
|
||||||
@@ -248,20 +261,24 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str
|
|||||||
|
|
||||||
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at`
|
p.manifest, p.enabled, p.status,
|
||||||
|
p.schema_version, p.package_settings,
|
||||||
|
p.source, p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
|
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -274,6 +291,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,14 +307,16 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
|
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -305,14 +325,80 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scoped visibility (v0.30.0) ──────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListVisiblePackages(ctx context.Context, userID string) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.enabled = 1
|
||||||
|
AND (
|
||||||
|
p.scope = 'global'
|
||||||
|
OR (p.scope = 'personal' AND p.installed_by = ?)
|
||||||
|
OR (p.scope = 'team' AND p.team_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = ?
|
||||||
|
))
|
||||||
|
)
|
||||||
|
ORDER BY p.source, p.title`, userID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Package lifecycle (v0.30.0) ──────────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) SetSchemaVersion(ctx context.Context, id string, version int) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET schema_version = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
version, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) {
|
||||||
|
var settings string
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT package_settings FROM packages WHERE id = ?`, id).Scan(&settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.RawMessage(settings), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error {
|
||||||
|
result, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE packages SET package_settings = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
string(settings), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
}
|
}
|
||||||
return sql.NullString{String: *s, Valid: true}
|
return sql.NullString{String: *s, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultJSON returns the raw message or '{}' if nil/empty.
|
||||||
|
func defaultJSON(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Renders in the "Packages" admin section via ADMIN_LOADERS.
|
// Renders in the "Packages" admin section via ADMIN_LOADERS.
|
||||||
// Replaces admin-surfaces.js (v0.28.7).
|
// Replaces admin-surfaces.js (v0.28.7).
|
||||||
|
// v0.30.0: scope column, settings, export, registry browse.
|
||||||
//
|
//
|
||||||
// Exports: window._loadAdminPackages
|
// Exports: window._loadAdminPackages
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ async function _loadAdminPackages() {
|
|||||||
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
|
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
|
||||||
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
|
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
|
||||||
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
|
'<button class="btn-small" id="pkgFilterFull">Full</button>' +
|
||||||
|
'<span style="flex:1"></span>' +
|
||||||
|
'<button class="btn-small" id="pkgBrowseRegistry">Browse Registry</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
|
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
|
||||||
@@ -30,6 +33,8 @@ async function _loadAdminPackages() {
|
|||||||
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
|
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div id="adminPkgSettingsPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
|
||||||
|
'<div id="adminPkgRegistryPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
|
||||||
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
|
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||||
|
|
||||||
var base = document.body.dataset.basePath || '';
|
var base = document.body.dataset.basePath || '';
|
||||||
@@ -41,11 +46,18 @@ async function _loadAdminPackages() {
|
|||||||
if (btn) {
|
if (btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
currentFilter = label === 'All' ? '' : label.toLowerCase();
|
currentFilter = label === 'All' ? '' : label.toLowerCase();
|
||||||
|
document.getElementById('adminPkgRegistryPanel').style.display = 'none';
|
||||||
loadList();
|
loadList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Browse Registry button
|
||||||
|
var browseBtn = document.getElementById('pkgBrowseRegistry');
|
||||||
|
if (browseBtn) {
|
||||||
|
browseBtn.addEventListener('click', loadRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
// Install form
|
// Install form
|
||||||
var installSubmit = document.getElementById('pkgInstallSubmit');
|
var installSubmit = document.getElementById('pkgInstallSubmit');
|
||||||
var installCancel = document.getElementById('pkgInstallCancel');
|
var installCancel = document.getElementById('pkgInstallCancel');
|
||||||
@@ -102,12 +114,13 @@ async function _loadAdminPackages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
|
var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
|
||||||
'<th style="width:30%">Package</th>' +
|
'<th style="width:25%">Package</th>' +
|
||||||
'<th>Type</th>' +
|
'<th>Type</th>' +
|
||||||
'<th>Version</th>' +
|
'<th>Version</th>' +
|
||||||
|
'<th>Scope</th>' +
|
||||||
'<th>Source</th>' +
|
'<th>Source</th>' +
|
||||||
'<th>Status</th>' +
|
'<th>Status</th>' +
|
||||||
'<th style="width:100px">Actions</th>' +
|
'<th style="width:140px">Actions</th>' +
|
||||||
'</tr></thead><tbody>';
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
packages.forEach(function(pkg) {
|
packages.forEach(function(pkg) {
|
||||||
@@ -121,8 +134,15 @@ async function _loadAdminPackages() {
|
|||||||
core: '<span class="text-muted">core</span>',
|
core: '<span class="text-muted">core</span>',
|
||||||
builtin: '<span class="text-muted">builtin</span>',
|
builtin: '<span class="text-muted">builtin</span>',
|
||||||
extension: '<span class="text-muted">installed</span>',
|
extension: '<span class="text-muted">installed</span>',
|
||||||
|
registry: '<span class="text-muted">registry</span>',
|
||||||
}[pkg.source] || pkg.source;
|
}[pkg.source] || pkg.source;
|
||||||
|
|
||||||
|
var scopeBadge = {
|
||||||
|
global: '<span class="text-muted">global</span>',
|
||||||
|
team: '<span class="badge badge-info">team</span>',
|
||||||
|
personal: '<span class="badge badge-accent">personal</span>',
|
||||||
|
}[pkg.scope] || pkg.scope;
|
||||||
|
|
||||||
var statusBadge = pkg.enabled
|
var statusBadge = pkg.enabled
|
||||||
? '<span class="badge badge-success">enabled</span>'
|
? '<span class="badge badge-success">enabled</span>'
|
||||||
: '<span class="badge badge-muted">disabled</span>';
|
: '<span class="badge badge-muted">disabled</span>';
|
||||||
@@ -136,6 +156,14 @@ async function _loadAdminPackages() {
|
|||||||
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
|
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Settings (if manifest has settings)
|
||||||
|
if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) {
|
||||||
|
actions += '<button class="btn-small btn-ghost pkg-settings" data-id="' + pkg.id + '" title="Settings">Settings</button> ';
|
||||||
|
}
|
||||||
|
// Export (non-core only)
|
||||||
|
if (pkg.source !== 'core') {
|
||||||
|
actions += '<button class="btn-small btn-ghost pkg-export" data-id="' + pkg.id + '" title="Export">Export</button> ';
|
||||||
|
}
|
||||||
// Delete (non-core only)
|
// Delete (non-core only)
|
||||||
if (pkg.source !== 'core') {
|
if (pkg.source !== 'core') {
|
||||||
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
|
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
|
||||||
@@ -143,11 +171,13 @@ async function _loadAdminPackages() {
|
|||||||
|
|
||||||
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
|
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
|
||||||
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
|
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
|
||||||
|
var schemaVer = pkg.schema_version > 0 ? ' <span class="text-muted" style="font-size:10px">v' + pkg.schema_version + '</span>' : '';
|
||||||
|
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td><strong>' + pkg.title + '</strong>' + system + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
|
'<td><strong>' + pkg.title + '</strong>' + system + schemaVer + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
|
||||||
'<td>' + typeBadge + '</td>' +
|
'<td>' + typeBadge + '</td>' +
|
||||||
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '—') + '</td>' +
|
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '\u2014') + '</td>' +
|
||||||
|
'<td>' + scopeBadge + '</td>' +
|
||||||
'<td>' + sourceBadge + '</td>' +
|
'<td>' + sourceBadge + '</td>' +
|
||||||
'<td>' + statusBadge + '</td>' +
|
'<td>' + statusBadge + '</td>' +
|
||||||
'<td>' + actions + '</td>' +
|
'<td>' + actions + '</td>' +
|
||||||
@@ -180,11 +210,170 @@ async function _loadAdminPackages() {
|
|||||||
loadList();
|
loadList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
listEl.querySelectorAll('.pkg-export').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
window.location = base + '/api/v1/admin/packages/' + btn.dataset.id + '/export';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('.pkg-settings').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
loadSettings(btn.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
|
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Settings panel ──────────────────────────
|
||||||
|
|
||||||
|
async function loadSettings(pkgId) {
|
||||||
|
var panel = document.getElementById('adminPkgSettingsPanel');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
panel.innerHTML = '<div class="loading">Loading settings...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await API._get('/api/v1/admin/packages/' + pkgId + '/settings');
|
||||||
|
var schema = resp.schema || [];
|
||||||
|
var values = resp.values || {};
|
||||||
|
|
||||||
|
if (typeof values === 'string') {
|
||||||
|
try { values = JSON.parse(values); } catch(e) { values = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.length === 0) {
|
||||||
|
panel.innerHTML = '<p class="text-muted" style="font-size:12px">No settings declared by this package.</p>' +
|
||||||
|
'<button class="btn-small" id="pkgSettingsClose">Close</button>';
|
||||||
|
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<h4 style="margin:0 0 12px 0;font-size:13px">Settings: ' + pkgId + '</h4>';
|
||||||
|
schema.forEach(function(s) {
|
||||||
|
var key = s.key;
|
||||||
|
var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : '');
|
||||||
|
html += '<div style="margin-bottom:10px">' +
|
||||||
|
'<label style="font-size:12px;display:block;margin-bottom:4px">' + (s.label || key) + '</label>';
|
||||||
|
|
||||||
|
if (s.type === 'select' && s.options) {
|
||||||
|
html += '<select class="pkg-setting-input" data-key="' + key + '" style="font-size:12px;padding:4px 8px">';
|
||||||
|
s.options.forEach(function(opt) {
|
||||||
|
html += '<option value="' + opt + '"' + (opt === val ? ' selected' : '') + '>' + opt + '</option>';
|
||||||
|
});
|
||||||
|
html += '</select>';
|
||||||
|
} else if (s.type === 'boolean') {
|
||||||
|
html += '<input type="checkbox" class="pkg-setting-input" data-key="' + key + '" data-type="boolean"' + (val ? ' checked' : '') + '>';
|
||||||
|
} else if (s.type === 'number') {
|
||||||
|
html += '<input type="number" class="pkg-setting-input" data-key="' + key + '" data-type="number" value="' + val + '" style="font-size:12px;padding:4px 8px;width:100px">';
|
||||||
|
} else {
|
||||||
|
html += '<input type="text" class="pkg-setting-input" data-key="' + key + '" value="' + val + '" style="font-size:12px;padding:4px 8px;width:200px">';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||||
|
'<button class="btn-small btn-primary" id="pkgSettingsSave">Save</button>' +
|
||||||
|
'<button class="btn-small" id="pkgSettingsClose">Close</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
panel.innerHTML = html;
|
||||||
|
|
||||||
|
document.getElementById('pkgSettingsSave').addEventListener('click', async function() {
|
||||||
|
var data = {};
|
||||||
|
panel.querySelectorAll('.pkg-setting-input').forEach(function(el) {
|
||||||
|
var key = el.dataset.key;
|
||||||
|
if (el.dataset.type === 'boolean') {
|
||||||
|
data[key] = el.checked;
|
||||||
|
} else if (el.dataset.type === 'number') {
|
||||||
|
data[key] = parseFloat(el.value) || 0;
|
||||||
|
} else {
|
||||||
|
data[key] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await API._put('/api/v1/admin/packages/' + pkgId + '/settings', data);
|
||||||
|
UI.toast('Settings saved', 'success');
|
||||||
|
panel.style.display = 'none';
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to save: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = '<div style="color:var(--danger)">Failed to load settings: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registry browse ─────────────────────────
|
||||||
|
|
||||||
|
async function loadRegistry() {
|
||||||
|
var panel = document.getElementById('adminPkgRegistryPanel');
|
||||||
|
panel.style.display = 'block';
|
||||||
|
panel.innerHTML = '<div class="loading">Loading registry...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await API._get('/api/v1/admin/packages/registry');
|
||||||
|
var packages = resp.packages || [];
|
||||||
|
var registryUrl = resp.registry_url || '';
|
||||||
|
|
||||||
|
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||||
|
'<h4 style="margin:0;font-size:13px">Package Registry</h4>' +
|
||||||
|
'<span style="flex:1"></span>' +
|
||||||
|
'<button class="btn-small" id="pkgRegistryClose">Close</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
if (!registryUrl) {
|
||||||
|
html += '<p class="text-muted" style="font-size:12px">No registry URL configured. Set <code>package_registry</code> in Admin Settings with a <code>{"url": "https://..."}</code> value.</p>';
|
||||||
|
} else if (packages.length === 0) {
|
||||||
|
html += '<p class="text-muted" style="font-size:12px">Registry is empty or returned no packages.</p>';
|
||||||
|
} else {
|
||||||
|
html += '<table class="admin-table" style="width:100%"><thead><tr>' +
|
||||||
|
'<th style="width:30%">Package</th><th>Version</th><th>Author</th><th>Actions</th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
|
||||||
|
packages.forEach(function(pkg) {
|
||||||
|
var installBtn = pkg.installed
|
||||||
|
? '<span class="badge badge-success">Installed</span>'
|
||||||
|
: '<button class="btn-small btn-primary registry-install" data-url="' + pkg.download_url + '">Install</button>';
|
||||||
|
|
||||||
|
html += '<tr>' +
|
||||||
|
'<td><strong>' + pkg.title + '</strong><div class="text-muted" style="font-size:11px">' +
|
||||||
|
(pkg.description || '') + '</div></td>' +
|
||||||
|
'<td style="font-family:monospace;font-size:12px">' + (pkg.version || '') + '</td>' +
|
||||||
|
'<td style="font-size:12px">' + (pkg.author || '') + '</td>' +
|
||||||
|
'<td>' + installBtn + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = html;
|
||||||
|
|
||||||
|
document.getElementById('pkgRegistryClose').addEventListener('click', function() { panel.style.display = 'none'; });
|
||||||
|
|
||||||
|
panel.querySelectorAll('.registry-install').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Installing...';
|
||||||
|
try {
|
||||||
|
await API._post('/api/v1/admin/packages/registry/install', {
|
||||||
|
download_url: btn.dataset.url,
|
||||||
|
});
|
||||||
|
UI.toast('Installed from registry', 'success');
|
||||||
|
loadList();
|
||||||
|
loadRegistry(); // refresh to show "Installed" badge
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Install failed: ' + e.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Install';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
panel.innerHTML = '<div style="color:var(--danger)">Failed to load registry: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadList();
|
loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user