Feat v0.3.8 distribution (#21)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m35s
CI/CD / test-sqlite (push) Successful in 2m45s
CI/CD / build-and-deploy (push) Successful in 30s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-03-28 22:46:40 +00:00
committed by xcaliber
parent d91ec02dd7
commit 310048b7bb
17 changed files with 944 additions and 11 deletions

View File

@@ -0,0 +1,309 @@
package handlers
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/triggers"
)
// InstallBundledPackages scans bundledDir for .pkg archives and installs
// any that don't already exist in the database. Called once at startup
// (unless SKIP_BUNDLED_PACKAGES=true).
//
// allowlist is an optional comma-separated list of package IDs to install.
// Empty string means install all. This allows Helm/K8s deployments to
// select a subset: e.g. "tasks,schedules,hello-dashboard".
//
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
// package, it won't be re-installed on the next restart.
func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner) {
entries, err := os.ReadDir(bundledDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[bundled] No bundled packages directory at %s — skipping", bundledDir)
return
}
log.Printf("[bundled] ⚠️ Cannot read bundled packages: %v", err)
return
}
// Parse allowlist into a set (empty = allow all)
allowed := parseBundleAllowlist(allowlist)
if len(allowed) > 0 {
log.Printf("[bundled] Allowlist: %v", allowlist)
}
var installed, skipped, filtered int
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pkg") {
continue
}
// Check allowlist by filename (strip .pkg extension = package ID)
if len(allowed) > 0 {
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
if !allowed[pkgName] {
filtered++
continue
}
}
pkgPath := filepath.Join(bundledDir, entry.Name())
result, err := installBundledPackage(pkgPath, packagesDir, stores, runner)
if err != nil {
log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err)
continue
}
if result == "skipped" {
skipped++
} else {
installed++
}
}
if installed > 0 || skipped > 0 || filtered > 0 {
msg := fmt.Sprintf("[bundled] Installed %d, skipped %d existing", installed, skipped)
if filtered > 0 {
msg += fmt.Sprintf(", filtered %d by allowlist", filtered)
}
log.Println(msg)
}
}
// parseBundleAllowlist parses a comma-separated string into a set of
// package IDs. Returns nil for empty input (meaning "allow all").
func parseBundleAllowlist(s string) map[string]bool {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
m := make(map[string]bool, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
m[p] = true
}
}
return m
}
// installBundledPackage installs a single .pkg archive if its package ID
// doesn't already exist in the database. Returns "installed" or "skipped".
func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner) (string, error) {
ctx := context.Background()
// Open as zip
zr, err := zip.OpenReader(pkgPath)
if err != nil {
return "", fmt.Errorf("invalid zip archive: %w", err)
}
defer zr.Close()
// Find and parse manifest.json
manifest, err := extractManifest(zr)
if err != nil {
return "", err
}
pkgID, _ := manifest["id"].(string)
if pkgID == "" {
return "", fmt.Errorf("manifest missing 'id'")
}
// Skip if already exists (admin uninstalled → stays uninstalled)
existing, _ := stores.Packages.Get(ctx, pkgID)
if existing != nil {
return "skipped", nil
}
title, _ := manifest["title"].(string)
if title == "" {
return "", fmt.Errorf("manifest missing 'title'")
}
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
// Extract static assets to packagesDir/{id}/
if packagesDir != "" {
if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil {
return "", fmt.Errorf("asset extraction: %w", err)
}
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
// Register in database via Seed (upsert)
if err := stores.Packages.Seed(ctx, pkgID, title, "bundled", manifest); err != nil {
return "", fmt.Errorf("seed failed: %w", err)
}
// Update fields that Seed doesn't set
pkg := &store.PackageRegistration{
Title: title,
Type: pkgType,
Version: version,
Description: description,
Author: author,
Tier: tier,
IsSystem: false,
Enabled: true,
Manifest: manifest,
}
if err := stores.Packages.Update(ctx, pkgID, pkg); err != nil {
log.Printf("[bundled] Failed to update package metadata for %s: %v", pkgID, err)
}
// Create namespaced DB tables declared in the manifest
if tables, ok := ParseDBTables(manifest); ok {
if err := CreateExtTables(ctx, database.DB, database.IsPostgres(), stores, pkgID, tables); err != nil {
log.Printf("[bundled] [ext-db] schema create failed for %s: %v", pkgID, err)
}
}
// Sync permissions — use a minimal gin.Context for the functions that need it
fakeCtx := newBackgroundGinContext()
SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
// Sync triggers from manifest
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
// Install workflow definition if applicable
if pkgType == "workflow" {
if err := InstallWorkflowFromManifest(fakeCtx, stores, pkgID, manifest); err != nil {
log.Printf("[bundled] workflow install failed for %s: %v", pkgID, err)
}
}
// Handle dormant status for packages with unmet requires
knownCaps := map[string]bool{}
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
var unmetReqs []string
for _, r := range reqs {
req, _ := r.(string)
if req != "" && !knownCaps[req] {
unmetReqs = append(unmetReqs, req)
}
}
if len(unmetReqs) > 0 {
stores.Packages.SetStatus(ctx, pkgID, "dormant")
stores.Packages.SetEnabled(ctx, pkgID, false)
log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
}
}
// Auto-set default_surface when the first surface is installed
if (pkgType == "surface" || pkgType == "full") {
if raw, err := stores.GlobalConfig.Get(ctx, "default_surface"); err != nil || raw == nil {
dflt := models.JSONMap{"id": pkgID}
if setErr := stores.GlobalConfig.Set(ctx, "default_surface", dflt, ""); setErr != nil {
log.Printf("[bundled] failed to auto-set default_surface to %s: %v", pkgID, setErr)
}
}
}
log.Printf("[bundled] Installed %s (%s) v%s", pkgID, pkgType, version)
return "installed", nil
}
// extractManifest reads and parses manifest.json from a zip archive.
func extractManifest(zr *zip.ReadCloser) (map[string]any, error) {
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, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("invalid manifest.json: %w", err)
}
return manifest, nil
}
}
return nil, fmt.Errorf("archive missing manifest.json")
}
// extractPackageAssets extracts static assets from a zip archive to packagesDir/{pkgID}/.
func extractPackageAssets(zr *zip.ReadCloser, packagesDir, pkgID string) error {
destDir := filepath.Join(packagesDir, pkgID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
relPath := extractableRelPath(f.Name)
if relPath == "" {
continue
}
destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue
}
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
return nil
}
// newBackgroundGinContext creates a minimal gin.Context backed by a
// background context. Used by startup code that calls functions
// originally designed for HTTP handlers.
func newBackgroundGinContext() *gin.Context {
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
c := &gin.Context{}
c.Request = req
return c
}

View File

@@ -0,0 +1,226 @@
package handlers
import (
"archive/zip"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"switchboard-core/database"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Test helpers ──────────────────────────────
func newTestStores(t *testing.T) store.Stores {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
if database.IsSQLite() {
return sqlite.NewStores(database.TestDB)
}
return postgres.NewStores(database.TestDB)
}
// buildTestPkg creates a minimal .pkg archive in dir with the given manifest.
func buildTestPkg(t *testing.T, dir string, manifest map[string]any) string {
t.Helper()
pkgID, _ := manifest["id"].(string)
if pkgID == "" {
t.Fatal("manifest must have 'id'")
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
pkgPath := filepath.Join(dir, pkgID+".pkg")
f, err := os.Create(pkgPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
w, err := zw.Create("manifest.json")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return pkgPath
}
// ═══════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════
// TestBundledInstall_FreshDB verifies that bundled packages are installed
// into an empty database on first run.
func TestBundledInstall_FreshDB(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "test-surface-a",
"title": "Test Surface A",
"type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "test-surface-b",
"title": "Test Surface B",
"type": "surface",
"route": "/s/test-b",
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
// Both packages should be installed and enabled
for _, id := range []string{"test-surface-a", "test-surface-b"} {
pkg, err := stores.Packages.Get(ctx, id)
if err != nil || pkg == nil {
t.Fatalf("package %s not found after install", id)
}
if !pkg.Enabled {
t.Errorf("package %s should be enabled", id)
}
if pkg.Source != "bundled" {
t.Errorf("package %s source = %q, want %q", id, pkg.Source, "bundled")
}
}
}
// TestBundledInstall_SkipsExisting verifies that already-installed packages
// are not re-installed (preserves admin uninstall/reconfigure).
func TestBundledInstall_SkipsExisting(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
// Pre-seed a package with same ID but different title (simulating existing install)
manifest := map[string]any{"id": "pre-existing", "title": "Original Title", "type": "surface"}
if err := stores.Packages.Seed(ctx, "pre-existing", "Original Title", "extension", manifest); err != nil {
t.Fatal(err)
}
// Bundled package has same ID but different title
buildTestPkg(t, bundledDir, map[string]any{
"id": "pre-existing",
"title": "Bundled Title",
"type": "surface",
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
// Package should retain original title (not overwritten)
pkg, err := stores.Packages.Get(ctx, "pre-existing")
if err != nil || pkg == nil {
t.Fatal("pre-existing package not found")
}
if pkg.Title != "Original Title" {
t.Errorf("title = %q, want %q (should not overwrite existing)", pkg.Title, "Original Title")
}
}
// TestBundledInstall_MissingDir verifies graceful handling when bundled
// packages directory doesn't exist.
func TestBundledInstall_MissingDir(t *testing.T) {
stores := newTestStores(t)
// Should not panic or error — just log and return
InstallBundledPackages("/nonexistent/path", "", "", stores, nil)
}
// TestBundledInstall_EmptyDir verifies no-op when directory exists but is empty.
func TestBundledInstall_EmptyDir(t *testing.T) {
stores := newTestStores(t)
bundledDir := t.TempDir()
// Should not panic or error
InstallBundledPackages(bundledDir, "", "", stores, nil)
}
// TestBundledInstall_DormantPackage verifies that bundled packages with
// unmet requires are marked dormant.
func TestBundledInstall_DormantPackage(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "dormant-pkg",
"title": "Dormant Package",
"type": "extension",
"requires": []string{"chat"},
})
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
pkg, err := stores.Packages.Get(ctx, "dormant-pkg")
if err != nil || pkg == nil {
t.Fatal("dormant package not found after install")
}
if pkg.Status != "dormant" {
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
}
if pkg.Enabled {
t.Error("dormant package should not be enabled")
}
}
// TestBundledInstall_Allowlist verifies that BUNDLED_PACKAGES allowlist
// filters which packages are installed.
func TestBundledInstall_Allowlist(t *testing.T) {
stores := newTestStores(t)
ctx := context.Background()
bundledDir := t.TempDir()
packagesDir := t.TempDir()
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-alpha", "title": "Alpha", "type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-beta", "title": "Beta", "type": "surface",
})
buildTestPkg(t, bundledDir, map[string]any{
"id": "pkg-gamma", "title": "Gamma", "type": "surface",
})
// Only allow alpha and gamma
InstallBundledPackages(bundledDir, packagesDir, "pkg-alpha,pkg-gamma", stores, nil)
// Alpha and gamma should be installed
for _, id := range []string{"pkg-alpha", "pkg-gamma"} {
pkg, err := stores.Packages.Get(ctx, id)
if err != nil || pkg == nil {
t.Fatalf("package %s should have been installed (in allowlist)", id)
}
}
// Beta should NOT be installed
pkg, _ := stores.Packages.Get(ctx, "pkg-beta")
if pkg != nil {
t.Fatal("pkg-beta should NOT have been installed (not in allowlist)")
}
}