Feat v0.3.8 distribution (#21)
All checks were successful
All checks were successful
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:
@@ -63,6 +63,15 @@ type Config struct {
|
||||
MTLSAutoActivate bool // auto-activate new users (default true)
|
||||
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
|
||||
// Bundled packages (v0.3.8)
|
||||
// SKIP_BUNDLED_PACKAGES: set true to disable auto-install of bundled packages on first run.
|
||||
// BUNDLED_PACKAGES_DIR: directory containing pre-built .pkg archives (default /app/bundled-packages).
|
||||
// BUNDLED_PACKAGES: comma-separated allowlist of package IDs to install (empty = all).
|
||||
// e.g. "tasks,schedules,hello-dashboard" installs only those three.
|
||||
SkipBundledPackages bool
|
||||
BundledPackagesDir string
|
||||
BundledPackages string
|
||||
|
||||
// OIDC (v0.24.1)
|
||||
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
|
||||
OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL
|
||||
@@ -105,6 +114,10 @@ func Load() *Config {
|
||||
S3Prefix: getEnv("S3_PREFIX", ""),
|
||||
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
||||
|
||||
SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false),
|
||||
BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"),
|
||||
BundledPackages: getEnv("BUNDLED_PACKAGES", ""),
|
||||
|
||||
LogFormat: getEnv("LOG_FORMAT", "text"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||
package_settings JSONB NOT NULL DEFAULT '{}',
|
||||
source TEXT NOT NULL DEFAULT 'core'
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled')),
|
||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 012_bundled_source.sql — v0.3.8
|
||||
-- Adds 'bundled' to the packages.source CHECK constraint for auto-installed packages.
|
||||
|
||||
ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_source_check;
|
||||
ALTER TABLE packages ADD CONSTRAINT packages_source_check
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled'));
|
||||
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS packages (
|
||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||
source TEXT NOT NULL DEFAULT 'core'
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled')),
|
||||
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
39
server/database/migrations/sqlite/012_bundled_source.sql
Normal file
39
server/database/migrations/sqlite/012_bundled_source.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- 012_bundled_source.sql — v0.3.8
|
||||
-- Adds 'bundled' to the packages.source CHECK constraint for auto-installed packages.
|
||||
-- SQLite doesn't support ALTER CHECK — must recreate the table.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'surface'
|
||||
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')),
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
tier TEXT NOT NULL DEFAULT 'browser'
|
||||
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')),
|
||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||
source TEXT NOT NULL DEFAULT 'core'
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry', 'bundled')),
|
||||
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO packages_new SELECT * FROM packages;
|
||||
DROP TABLE packages;
|
||||
ALTER TABLE packages_new RENAME TO packages;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
|
||||
309
server/handlers/packages_bundled.go
Normal file
309
server/handlers/packages_bundled.go
Normal 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
|
||||
}
|
||||
226
server/handlers/packages_bundled_test.go
Normal file
226
server/handlers/packages_bundled_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,17 @@ func main() {
|
||||
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
|
||||
}
|
||||
|
||||
// ── Bundled Packages (v0.3.8) ───────────────
|
||||
// Auto-install bundled .pkg archives on first run.
|
||||
// Skipped if SKIP_BUNDLED_PACKAGES=true or packages already exist.
|
||||
if !cfg.SkipBundledPackages && stores.Packages != nil {
|
||||
bundledPkgDir := ""
|
||||
if cfg.StoragePath != "" {
|
||||
bundledPkgDir = cfg.StoragePath + "/packages"
|
||||
}
|
||||
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner)
|
||||
}
|
||||
|
||||
// ── Trigger Engine (v0.2.2) ─────────────────
|
||||
triggerEngine := triggers.New(stores, starlarkRunner, bus)
|
||||
if err := triggerEngine.Start(context.Background()); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user