diff --git a/CHANGELOG.md b/CHANGELOG.md index 1656d4b..88e3da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to Switchboard Core are documented here. +## v0.5.4 — Package Updates + +### Added + +- **Package update API**: `POST /api/v1/admin/packages/:id/update` accepts a + `.pkg` archive, validates semver version bump (rejects same/older), applies + additive schema migration, merges settings, replaces assets, and re-syncs + permissions/triggers/dependencies. +- **Package export API**: `GET /api/v1/admin/packages/:id/export` streams the + installed package as a downloadable `.pkg` ZIP archive. Completes the manual + rollback story: export before update, re-install old `.pkg` if needed. +- **Semver parsing**: `ParseSemver` / `Compare` in `handlers/semver.go` with + prerelease support. Used by the update handler to enforce version bumps. +- **Additive schema migration**: `MigrateExtTables` diffs declared `db_tables` + against existing ext_data schema. Adds new columns (`ALTER TABLE ADD COLUMN`) + and new tables. No destructive changes — columns in DB but absent from + manifest are preserved. `ListExtColumns` introspects via `PRAGMA table_info` + (SQLite) or `information_schema` (Postgres). +- **Settings merge on update**: New manifest setting keys get their declared + default value; existing admin-configured values are preserved. +- **Admin UI Update button**: Non-core package cards now show an Update button + with a confirm dialog before upload. +- **14 new Go tests**: Version bump, 5 rejection cases (same/older/core/type/id + mismatch), schema add column with data preservation, schema add table, + settings merge, export with valid zip, default allowlist, wildcard allowlist. + +### Changed + +- **Curated default bundle**: Default bundled packages reduced from 23 to 8 + core packages (notes, chat-core, workflow-chat, dashboard, 4 demo workflows). + `BUNDLED_PACKAGES=*` installs all; explicit comma-separated list for custom. + Existing deployments are unaffected (install-once, skip-if-present). +- **`BUNDLED_PACKAGES` env var**: Empty value now installs curated defaults + instead of all packages. Use `"*"` for previous install-all behavior. + ## v0.5.3 — Chat Polish + Integration Testing ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 79a75f1..0f1464d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -389,16 +389,17 @@ participation is post-MVP. | "Requires Review" filter | Dropdown filter option or dedicated button for `pending_review` packages. Useful when the package list grows long. | | Extreme UI scale polish | At 150%+ scale: user menu items overflow viewport (need scroll), topbar elements clipped. Ensure all surfaces and menus are usable at scale extremes (80%–200%). | -### v0.5.4 — Package Updates (planned) +### v0.5.4 — Package Updates | Step | Status | Description | |------|--------|-------------| -| Version comparison | | Compare installed package version against incoming `.pkg` version. Semantic version parsing. Skip if same or older. | -| Schema migration on update | | Diff declared `db_tables` against existing ext_data schema. Add new columns, add new tables. No destructive changes (no column drops, no type changes). | -| Data preservation | | ext_data tables survive update. Only additive schema changes applied. Settings merged (new keys added, existing preserved). | -| Update API | | `PUT /api/v1/packages/:id/update` — accepts `.pkg` archive, validates version bump, applies schema diff, replaces code (JS/CSS/Starlark). | -| Admin UI | | Update button on package card when newer version available. Version comparison badge. Update confirmation via `sw.ui.Confirm`. | -| Rollback story | | Export package before update (existing export). Document manual rollback: re-install old `.pkg`. No automatic rollback — KISS. | +| Curate bundled packages | ✅ | Default set reduced from 23 to 8 core packages. `BUNDLED_PACKAGES=*` for all; explicit list for custom. | +| Version comparison | ✅ | Semver parsing (`ParseSemver`) with prerelease support. Version bump validated on update. | +| Schema migration on update | ✅ | `MigrateExtTables` diffs `db_tables` against existing schema. Adds new columns/tables. No destructive changes. | +| Data preservation | ✅ | ext_data tables survive update. Settings merged (new keys added with defaults, existing preserved). | +| Update API | ✅ | `POST /api/v1/admin/packages/:id/update` — accepts `.pkg` archive, validates version bump, applies schema diff, replaces code. | +| Admin UI | ✅ | Update button on non-core package cards. Confirm dialog before upload. | +| Export API | ✅ | `GET /api/v1/admin/packages/:id/export` — streams installed package as .pkg ZIP. Manual rollback: export → update → re-install old .pkg if needed. | ### v0.5.5 — Upgrade Testing (planned) diff --git a/VERSION b/VERSION index be14282..7d85683 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 6cd8565..3c77f3f 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -14,42 +14,66 @@ On first run, bundled packages are automatically installed — workflows, surfac ## Bundled Packages -The production Docker image ships with pre-built packages that are auto-installed on first boot: +The production Docker image ships with pre-built packages. A **curated default set** is auto-installed on first boot; additional packages ship in the image and can be opted-in via `BUNDLED_PACKAGES`. + +#### Default Set (auto-installed) | Package | Type | Description | |---------|------|-------------| +| notes | surface | Markdown notes with graph view | +| chat-core | library | Conversations, messages, read cursors | +| workflow-chat | extension | Chat integration for workflow stages | +| dashboard | surface | Dashboard surface | +| workflow-demo | surface | Interactive walkthrough with diagrams | | bug-report-triage | workflow | Public entry, severity routing, SLA timers | | content-approval | workflow | Multi-party signoff example | | employee-onboarding | workflow | Automated provisioning + manager signoff | -| webhook-notifier | workflow | HTTP outbound via connections + Starlark | -| workflow-demo | surface | Interactive walkthrough with diagrams | -| hello-dashboard | surface | Welcome/getting started surface | -| schedules | surface | Cron task management UI | + +#### Opt-in (ship in image, require `BUNDLED_PACKAGES` to enable) + +| Package | Type | Description | +|---------|------|-------------| | tasks | full | Kanban/list task manager with webhooks | +| schedules | surface | Cron task management UI | +| editor | surface | Rich markdown editor | +| hello-dashboard | surface | Welcome/getting started surface | | team-activity-log | surface | Team activity feed | +| webhook-notifier | workflow | HTTP outbound via connections + Starlark | +| csv-table | extension | CSV table renderer | +| diff-viewer | extension | Diff visualization | +| git-board | surface | Git board interface | | gitea-client | library | Gitea API integration library | +| js-sandbox | extension | JavaScript sandbox | +| katex-renderer | extension | LaTeX rendering | +| mermaid-renderer | extension | Diagram rendering | +| regex-tester | extension | Regex testing tool | | icd-test-runner | surface | E2E API test suite | | sdk-test-runner | surface | SDK feature test suite | ### Behavior -- **First boot**: All bundled packages are installed and enabled automatically. +- **First boot**: Curated default packages are installed and enabled automatically. - **Subsequent boots**: No-op — already-installed packages are skipped. - **Admin uninstalls a package**: It stays uninstalled. Bundled packages are never force-reinstalled. - **To re-install**: Delete the package from the database, then restart the container. ### Selecting Packages (Allowlist) -Set `BUNDLED_PACKAGES` to a comma-separated list of package IDs to install only a subset: +Set `BUNDLED_PACKAGES` to control which packages are installed: ```bash -# K8s / Helm — install only core surfaces, no demo/test packages +# Install ALL packages (everything in the image) docker run -p 8080:80 \ - -e BUNDLED_PACKAGES="tasks,schedules,hello-dashboard" \ + -e BUNDLED_PACKAGES="*" \ + ghcr.io/switchboard-core/switchboard-core:latest + +# Install specific packages only +docker run -p 8080:80 \ + -e BUNDLED_PACKAGES="notes,tasks,schedules" \ ghcr.io/switchboard-core/switchboard-core:latest ``` -Empty (default) means install all bundled packages. This is useful for Helm charts where different environments need different packages. +Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages. ### Disabling Auto-Install @@ -149,7 +173,7 @@ docker build -t my-switchboard . | `STORAGE_PATH` | `/data/storage` | PVC mount point | | `BASE_PATH` | | URL prefix (e.g. `/switchboard`) | | `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages | -| `BUNDLED_PACKAGES` | (empty = all) | Comma-separated allowlist of package IDs to install | +| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs | | `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location | | `LOG_FORMAT` | `text` | `text` or `json` | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | diff --git a/server/config/config.go b/server/config/config.go index 1b05acb..2b87845 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -63,11 +63,13 @@ 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) + // Bundled packages (v0.3.8, curated v0.5.4) // 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. + // BUNDLED_PACKAGES: controls which bundled packages are auto-installed: + // empty → curated default set (notes, chat-core, workflow-chat, dashboard, demo workflows) + // "*" → install all .pkg archives found in the directory + // "a,b" → comma-separated allowlist of specific package IDs SkipBundledPackages bool BundledPackagesDir string BundledPackages string diff --git a/server/handlers/ext_db_schema.go b/server/handlers/ext_db_schema.go index 89ee8d7..c29fdf4 100644 --- a/server/handlers/ext_db_schema.go +++ b/server/handlers/ext_db_schema.go @@ -186,6 +186,130 @@ func CreateExtTables( return nil } +// ListExtColumns returns the set of column names for a physical table. +// Uses PRAGMA table_info on SQLite and information_schema on Postgres. +func ListExtColumns(ctx context.Context, db *sql.DB, isPostgres bool, physicalTable string) (map[string]bool, error) { + cols := make(map[string]bool) + + var rows *sql.Rows + var err error + if isPostgres { + rows, err = db.QueryContext(ctx, + `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`, + physicalTable) + } else { + rows, err = db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", physicalTable)) + } + if err != nil { + return nil, fmt.Errorf("list columns for %s: %w", physicalTable, err) + } + defer rows.Close() + + for rows.Next() { + if isPostgres { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + cols[name] = true + } else { + // PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk + var cid int + var name, colType string + var notnull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil { + continue + } + cols[name] = true + } + } + return cols, rows.Err() +} + +// MigrateExtTables applies additive-only schema changes for a package update. +// For each table in the new manifest: +// - New table: CREATE TABLE (full creation, same as CreateExtTables) +// - Existing table: ALTER TABLE ADD COLUMN for each new column +// +// Indexes are applied idempotently with CREATE INDEX IF NOT EXISTS. +// New tables are registered in the ext_data_tables catalog. +// Returns a log of changes applied (for API response). +// +// No destructive changes: columns present in DB but absent from manifest are NOT dropped. +func MigrateExtTables( + ctx context.Context, + db *sql.DB, + isPostgres bool, + stores store.Stores, + packageID string, + newTables map[string]TableDef, +) ([]string, error) { + if db == nil { + return nil, nil + } + + // Get existing registered tables for this package + existingTableNames := make(map[string]bool) + if stores.ExtData != nil { + names, err := stores.ExtData.ListTables(ctx, packageID) + if err != nil { + return nil, fmt.Errorf("list existing tables for %s: %w", packageID, err) + } + for _, n := range names { + existingTableNames[n] = true + } + } + + var changes []string + + for logicalName, td := range newTables { + physical := extPhysicalTable(packageID, logicalName) + + if !existingTableNames[logicalName] { + // New table — full creation + if err := CreateExtTables(ctx, db, isPostgres, stores, packageID, + map[string]TableDef{logicalName: td}); err != nil { + return changes, fmt.Errorf("create new table %s: %w", physical, err) + } + changes = append(changes, fmt.Sprintf("created table %s", physical)) + continue + } + + // Existing table — diff columns and add missing ones + existingCols, err := ListExtColumns(ctx, db, isPostgres, physical) + if err != nil { + return changes, err + } + + for col, typ := range td.Columns { + if existingCols[col] { + continue + } + sqlType := mapColType(typ, isPostgres) + alterDDL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", physical, col, sqlType) + if _, err := db.ExecContext(ctx, alterDDL); err != nil { + return changes, fmt.Errorf("add column %s.%s: %w", physical, col, err) + } + changes = append(changes, fmt.Sprintf("added column '%s' to %s", col, physical)) + log.Printf("[ext-db] added column %s (%s) to %s", col, sqlType, physical) + } + + // Re-apply indexes idempotently + for i, idxCols := range td.Indexes { + idxName := fmt.Sprintf("idx_%s_%d", physical, i) + idxDDL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", + idxName, physical, strings.Join(idxCols, ", ")) + if _, err := db.ExecContext(ctx, idxDDL); err != nil { + log.Printf("[ext-db] index create failed (%s): %v", idxName, err) + } + } + } + + return changes, nil +} + // DropExtTables drops all physical tables registered for a package and removes // their entries from the ext_data_tables catalog. // diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 8344a48..6850b85 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -897,3 +897,343 @@ func (h *PackageHandler) TestTool(c *gin.Context) { "output": output, }) } + +// ── Package update (v0.5.4) ────────────────────── + +// UpdatePackage applies an in-place update to an existing package. +// POST /api/v1/admin/packages/:id/update +// +// Validates version bump (semver), applies additive schema migration, +// merges settings, replaces assets, and re-syncs permissions/triggers. +func (h *PackageHandler) UpdatePackage(c *gin.Context) { + ctx := c.Request.Context() + pkgID := c.Param("id") + + // 1. Lookup existing package + existing, err := h.stores.Packages.Get(ctx, pkgID) + if err != nil || existing == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + if existing.Source == "core" { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot update core packages"}) + return + } + + // 2. Accept file upload + 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 + } + + tmpFile, err := os.CreateTemp("", "package-update-*.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() + + zr, err := zip.OpenReader(tmpPath) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) + return + } + defer zr.Close() + + // 3. Extract and validate manifest + manifest, err := extractManifest(zr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + newID, _ := manifest["id"].(string) + if newID != pkgID { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("manifest id %q does not match package %q", newID, pkgID)}) + return + } + + newType, _ := manifest["type"].(string) + if newType == "" { + newType = "surface" + } + if newType != existing.Type { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot change package type from %q to %q", existing.Type, newType)}) + return + } + + // 4. Version comparison — reject same or older + newVersion, _ := manifest["version"].(string) + if newVersion == "" { + newVersion = "0.0.0" + } + oldSemver, errOld := ParseSemver(existing.Version) + newSemver, errNew := ParseSemver(newVersion) + if errOld != nil || errNew != nil { + // If either version is unparseable, allow the update (legacy data) + if errNew != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version in manifest: " + errNew.Error()}) + return + } + } else if newSemver.Compare(oldSemver) <= 0 { + c.JSON(http.StatusConflict, gin.H{ + "error": fmt.Sprintf("version %s is not newer than installed %s", newVersion, existing.Version), + }) + return + } + + // 5. Unicode security scan + scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true} + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(f.Name)) + if !scanExts[ext] { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + findings := sandbox.ScanSource(string(data), f.Name) + if len(findings) > 0 { + if blocked, reason := sandbox.Verdict(findings); blocked { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": "extension_blocked", + "reason": fmt.Sprintf("%s (file: %s)", reason, f.Name), + }) + return + } + } + } + + // 6. Additive schema migration + var schemaChanges []string + if tables, ok := ParseDBTables(manifest); ok { + changes, err := MigrateExtTables(ctx, database.DB, database.IsPostgres(), h.stores, pkgID, tables) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "schema migration failed: " + err.Error()}) + return + } + schemaChanges = changes + } + + // 7. Settings merge — add new keys with defaults, preserve existing + mergedSettings := mergePackageSettings(existing, manifest) + if mergedSettings != nil { + if err := h.stores.Packages.SetPackageSettings(ctx, pkgID, mergedSettings); err != nil { + log.Printf("[packages] settings merge failed for %s: %v", pkgID, err) + } + } + + // 8. Replace assets + if h.packagesDir != "" { + if err := extractPackageAssets(zr, h.packagesDir, pkgID); err != nil { + log.Printf("[packages] asset extraction failed for %s: %v", pkgID, err) + } + } + + // 9. Update DB record + description, _ := manifest["description"].(string) + author, _ := manifest["author"].(string) + tier, _ := manifest["tier"].(string) + if tier == "" { + tier = "browser" + } + + pkg := &store.PackageRegistration{ + Title: manifest["title"].(string), + Type: newType, + Version: newVersion, + Description: description, + Author: author, + Tier: tier, + IsSystem: existing.IsSystem, + Enabled: existing.Enabled, + Manifest: manifest, + } + if err := h.stores.Packages.Update(ctx, pkgID, pkg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package record"}) + return + } + + // 10. Re-sync permissions, triggers, dependencies + SyncManifestPermissions(c, h.stores, pkgID, manifest) + SyncManifestTriggers(ctx, h.stores, triggers.GlobalEngine(), pkgID, manifest) + + if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 { + if h.stores.Dependencies != nil { + h.stores.Dependencies.DeleteAllForConsumer(ctx, pkgID) + } + for libID, vSpec := range deps { + lib, err := h.stores.Packages.Get(ctx, libID) + if err != nil || lib == nil { + log.Printf("[packages] update dependency %s not found for %s", libID, pkgID) + continue + } + versionSpec, _ := vSpec.(string) + if versionSpec == "" { + versionSpec = ">=0.0.0" + } + if h.stores.Dependencies != nil { + h.stores.Dependencies.Create(ctx, &models.ExtDependency{ + ConsumerID: pkgID, + LibraryID: libID, + VersionSpec: versionSpec, + ResolvedVer: lib.Version, + }) + } + } + } + + // 11. Re-install workflow definition if applicable + if newType == "workflow" { + if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil { + log.Printf("[packages] workflow update failed for %s: %v", pkgID, err) + } + } + + previousVersion := existing.Version + log.Printf("[packages] Updated %s from v%s to v%s", pkgID, previousVersion, newVersion) + + c.JSON(http.StatusOK, gin.H{ + "id": pkgID, + "version": newVersion, + "previous_version": previousVersion, + "changes": schemaChanges, + }) +} + +// mergePackageSettings merges existing settings with a new manifest's settings schema. +// New keys get their declared default value; existing keys are preserved. +func mergePackageSettings(existing *store.PackageRegistration, manifest map[string]any) json.RawMessage { + newSchema, _ := manifest["settings"].([]any) + if len(newSchema) == 0 { + return nil + } + + // Parse existing settings + var current map[string]any + if existing.PackageSettings != nil { + if err := json.Unmarshal(existing.PackageSettings, ¤t); err != nil { + current = make(map[string]any) + } + } + if current == nil { + current = make(map[string]any) + } + + changed := false + for _, entry := range newSchema { + e, ok := entry.(map[string]any) + if !ok { + continue + } + key, _ := e["key"].(string) + if key == "" { + continue + } + if _, exists := current[key]; !exists { + current[key] = e["default"] + changed = true + } + } + + if !changed { + return nil + } + + data, _ := json.Marshal(current) + return data +} + +// ── Package export (v0.5.4) ────────────────────── + +// ExportPackage exports an installed package as a downloadable .pkg archive. +// GET /api/v1/admin/packages/:id/export +func (h *PackageHandler) ExportPackage(c *gin.Context) { + ctx := c.Request.Context() + pkgID := c.Param("id") + + pkg, err := h.stores.Packages.Get(ctx, pkgID) + if err != nil || pkg == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + + // Set response headers + filename := fmt.Sprintf("%s-%s.pkg", pkgID, pkg.Version) + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + // Write manifest.json from DB + manifestJSON, err := json.MarshalIndent(pkg.Manifest, "", " ") + if err != nil { + log.Printf("[packages] export: failed to marshal manifest for %s: %v", pkgID, err) + return + } + mw, err := zw.Create("manifest.json") + if err != nil { + return + } + mw.Write(manifestJSON) + + // Walk packagesDir/{id}/ and add all asset files + if h.packagesDir == "" { + return + } + pkgDir := filepath.Join(h.packagesDir, pkgID) + filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + relPath, err := filepath.Rel(pkgDir, path) + if err != nil { + return nil + } + relPath = filepath.ToSlash(relPath) // normalize to forward slashes in zip + + fw, err := zw.Create(relPath) + if err != nil { + return nil + } + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + io.Copy(fw, f) + return nil + }) +} diff --git a/server/handlers/packages_bundled.go b/server/handlers/packages_bundled.go index df25ce7..97f7d32 100644 --- a/server/handlers/packages_bundled.go +++ b/server/handlers/packages_bundled.go @@ -22,13 +22,28 @@ import ( "switchboard-core/triggers" ) +// defaultBundledPackages is the curated set of packages installed by default. +// Other packages still ship in the Docker image but require BUNDLED_PACKAGES +// to be set explicitly (or "*" for all). +var defaultBundledPackages = map[string]bool{ + "notes": true, + "chat-core": true, + "workflow-chat": true, + "dashboard": true, + "workflow-demo": true, + "bug-report-triage": true, + "content-approval": true, + "employee-onboarding": true, +} + // 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". +// allowlist controls which packages are installed: +// - Empty string → install the curated default set (see defaultBundledPackages) +// - "*" → install all .pkg archives found in the directory +// - Comma-separated IDs → install only those (e.g. "tasks,schedules") // // Design: install-once, skip-if-present. If an admin uninstalls a bundled // package, it won't be re-installed on the next restart. @@ -43,10 +58,14 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st return } - // Parse allowlist into a set (empty = allow all) + // Parse allowlist: "" → curated defaults, "*" → all, "a,b" → explicit list allowed := parseBundleAllowlist(allowlist) - if len(allowed) > 0 { - log.Printf("[bundled] Allowlist: %v", allowlist) + if allowed != nil { + if allowlist == "" || allowlist == " " { + log.Printf("[bundled] Using curated default set (%d packages)", len(defaultBundledPackages)) + } else { + log.Printf("[bundled] Allowlist: %v", allowlist) + } } var installed, skipped, filtered int @@ -87,10 +106,16 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st } // parseBundleAllowlist parses a comma-separated string into a set of -// package IDs. Returns nil for empty input (meaning "allow all"). +// package IDs. Returns: +// - nil for "*" input (meaning "install all") +// - defaultBundledPackages for empty input +// - explicit set for comma-separated list func parseBundleAllowlist(s string) map[string]bool { s = strings.TrimSpace(s) if s == "" { + return defaultBundledPackages + } + if s == "*" { return nil } parts := strings.Split(s, ",") diff --git a/server/handlers/packages_bundled_test.go b/server/handlers/packages_bundled_test.go index 170ef80..8fcb284 100644 --- a/server/handlers/packages_bundled_test.go +++ b/server/handlers/packages_bundled_test.go @@ -88,7 +88,7 @@ func TestBundledInstall_FreshDB(t *testing.T) { "route": "/s/test-b", }) - InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) // Both packages should be installed and enabled for _, id := range []string{"test-surface-a", "test-surface-b"} { @@ -127,7 +127,7 @@ func TestBundledInstall_SkipsExisting(t *testing.T) { "type": "surface", }) - InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) // Package should retain original title (not overwritten) pkg, err := stores.Packages.Get(ctx, "pre-existing") @@ -174,7 +174,7 @@ func TestBundledInstall_DormantPackage(t *testing.T) { "requires": []string{"chat"}, }) - InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) pkg, err := stores.Packages.Get(ctx, "dormant-pkg") if err != nil || pkg == nil { diff --git a/server/handlers/packages_update_test.go b/server/handlers/packages_update_test.go new file mode 100644 index 0000000..933732a --- /dev/null +++ b/server/handlers/packages_update_test.go @@ -0,0 +1,494 @@ +package handlers + +import ( + "archive/zip" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "switchboard-core/database" + "switchboard-core/store" +) + +// ── Helpers ──────────────────────────────────── + +// buildPkgBytes creates a .pkg archive in memory and returns the ZIP bytes. +func buildPkgBytes(t *testing.T, manifest map[string]any) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + data, _ := json.Marshal(manifest) + w, _ := zw.Create("manifest.json") + w.Write(data) + zw.Close() + return buf.Bytes() +} + +// seedPackage installs a package directly into the DB for testing. +func seedPackage(t *testing.T, stores store.Stores, manifest map[string]any) { + t.Helper() + ctx := context.Background() + id := manifest["id"].(string) + title := manifest["title"].(string) + source := "extension" + if s, ok := manifest["source"].(string); ok { + source = s + } + if err := stores.Packages.Seed(ctx, id, title, source, manifest); err != nil { + t.Fatal(err) + } + version, _ := manifest["version"].(string) + if version == "" { + version = "0.0.0" + } + pkgType, _ := manifest["type"].(string) + if pkgType == "" { + pkgType = "surface" + } + tier, _ := manifest["tier"].(string) + if tier == "" { + tier = "browser" + } + pkg := &store.PackageRegistration{ + Title: title, + Type: pkgType, + Version: version, + Tier: tier, + Enabled: true, + Manifest: manifest, + } + if err := stores.Packages.Update(ctx, id, pkg); err != nil { + t.Fatalf("seedPackage Update failed for %s: %v", id, err) + } + // Verify version was actually stored + check, _ := stores.Packages.Get(ctx, id) + if check != nil && check.Version != version { + t.Fatalf("seedPackage: version not stored correctly for %s: got %q want %q", id, check.Version, version) + } +} + +// setupUpdateRouter creates a gin router with the update and export routes. +func setupUpdateRouter(t *testing.T, stores store.Stores) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + handler := NewPackageHandler(stores) + r.POST("/api/v1/admin/packages/:id/update", handler.UpdatePackage) + r.GET("/api/v1/admin/packages/:id/export", handler.ExportPackage) + return r +} + +// doUpdate performs a multipart upload to the update endpoint. +func doUpdate(router *gin.Engine, pkgID string, pkgBytes []byte) *httptest.ResponseRecorder { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.pkg") + part.Write(pkgBytes) + writer.Close() + + req := httptest.NewRequest("POST", "/api/v1/admin/packages/"+pkgID+"/update", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// ── Tests ──────────────────────────────────── + +func TestUpdatePackage_VersionBump(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + seedPackage(t, stores, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.1.0", + }) + + w := doUpdate(router, "test-pkg", pkg) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify version updated + updated, _ := stores.Packages.Get(ctx, "test-pkg") + if updated.Version != "1.1.0" { + t.Errorf("version = %q, want %q", updated.Version, "1.1.0") + } + + // Check response body + var resp map[string]any + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["version"] != "1.1.0" { + t.Errorf("response version = %v, want 1.1.0", resp["version"]) + } + if resp["previous_version"] != "1.0.0" { + t.Errorf("response previous_version = %v, want 1.0.0", resp["previous_version"]) + } +} + +func TestUpdatePackage_SameVersion_Rejected(t *testing.T) { + stores := newTestStores(t) + + seedPackage(t, stores, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + w := doUpdate(router, "test-pkg", pkg) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_OlderVersion_Rejected(t *testing.T) { + stores := newTestStores(t) + + seedPackage(t, stores, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "2.0.0", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + w := doUpdate(router, "test-pkg", pkg) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_CorePackage_Rejected(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + // Seed as core package + stores.Packages.Seed(ctx, "core-pkg", "Core", "core", map[string]any{ + "id": "core-pkg", "title": "Core", "type": "surface", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "core-pkg", "title": "Core", "type": "surface", "version": "2.0.0", + }) + + w := doUpdate(router, "core-pkg", pkg) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_TypeMismatch_Rejected(t *testing.T) { + stores := newTestStores(t) + + seedPackage(t, stores, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "extension", "version": "2.0.0", + "tools": []any{map[string]any{"name": "test"}}, + }) + + w := doUpdate(router, "test-pkg", pkg) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_IDMismatch_Rejected(t *testing.T) { + stores := newTestStores(t) + + seedPackage(t, stores, map[string]any{ + "id": "test-pkg", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "other-pkg", "title": "Other", "type": "surface", "version": "2.0.0", + }) + + w := doUpdate(router, "test-pkg", pkg) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_NotFound(t *testing.T) { + stores := newTestStores(t) + + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "nonexistent", "title": "Test", "type": "surface", "version": "1.0.0", + }) + + w := doUpdate(router, "nonexistent", pkg) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdatePackage_SchemaAddColumn(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + // Install v1 with a table that has one column + seedPackage(t, stores, map[string]any{ + "id": "schema-pkg", "title": "Schema Test", "type": "surface", "version": "1.0.0", + "db_tables": map[string]any{ + "items": map[string]any{ + "columns": map[string]any{"name": "text"}, + }, + }, + }) + // Actually create the table + tables, _ := ParseDBTables(map[string]any{ + "db_tables": map[string]any{ + "items": map[string]any{ + "columns": map[string]any{"name": "text"}, + }, + }, + }) + CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "schema-pkg", tables) + + // Insert a row to verify data preservation + physical := extPhysicalTable("schema-pkg", "items") + _, err := database.TestDB.ExecContext(ctx, + fmt.Sprintf("INSERT INTO %s (id, name) VALUES ('row1', 'hello')", physical)) + if err != nil { + t.Fatal(err) + } + + // Update to v2 with an added column + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "schema-pkg", "title": "Schema Test", "type": "surface", "version": "2.0.0", + "db_tables": map[string]any{ + "items": map[string]any{ + "columns": map[string]any{"name": "text", "priority": "int"}, + }, + }, + }) + + w := doUpdate(router, "schema-pkg", pkg) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify new column exists and old data preserved + var name string + var priority sql.NullInt64 + err = database.TestDB.QueryRowContext(ctx, + fmt.Sprintf("SELECT name, priority FROM %s WHERE id = 'row1'", physical)). + Scan(&name, &priority) + if err != nil { + t.Fatal("query failed after schema migration:", err) + } + if name != "hello" { + t.Errorf("name = %q, want %q (data should be preserved)", name, "hello") + } +} + +func TestUpdatePackage_SchemaAddTable(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + // Install v1 with no tables + seedPackage(t, stores, map[string]any{ + "id": "schema-pkg-b", "title": "Schema B", "type": "surface", "version": "1.0.0", + }) + + // Update to v2 with a new table + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "schema-pkg-b", "title": "Schema B", "type": "surface", "version": "2.0.0", + "db_tables": map[string]any{ + "audit": map[string]any{ + "columns": map[string]any{"action": "text", "actor": "text"}, + }, + }, + }) + + w := doUpdate(router, "schema-pkg-b", pkg) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify table was created + physical := extPhysicalTable("schema-pkg-b", "audit") + _, err := database.TestDB.ExecContext(ctx, + fmt.Sprintf("INSERT INTO %s (id, action, actor) VALUES ('row1', 'test', 'user1')", physical)) + if err != nil { + t.Fatal("new table should exist after update:", err) + } +} + +func TestUpdatePackage_SettingsMerge(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + // Install with initial settings + seedPackage(t, stores, map[string]any{ + "id": "settings-pkg", "title": "Settings Test", "type": "surface", "version": "1.0.0", + "settings": []any{ + map[string]any{"key": "color", "type": "text", "default": "blue"}, + }, + }) + // Set existing settings value + existing := json.RawMessage(`{"color":"red"}`) + stores.Packages.SetPackageSettings(ctx, "settings-pkg", existing) + + // Update with new settings key + router := setupUpdateRouter(t, stores) + pkg := buildPkgBytes(t, map[string]any{ + "id": "settings-pkg", "title": "Settings Test", "type": "surface", "version": "2.0.0", + "settings": []any{ + map[string]any{"key": "color", "type": "text", "default": "blue"}, + map[string]any{"key": "size", "type": "int", "default": float64(12)}, + }, + }) + + w := doUpdate(router, "settings-pkg", pkg) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify settings: color preserved as "red", size added with default 12 + raw, _ := stores.Packages.GetPackageSettings(ctx, "settings-pkg") + var settings map[string]any + json.Unmarshal(raw, &settings) + + if settings["color"] != "red" { + t.Errorf("color = %v, want %q (existing should be preserved)", settings["color"], "red") + } + if settings["size"] != float64(12) { + t.Errorf("size = %v, want %v (new key should get default)", settings["size"], 12) + } +} + +func TestExportPackage_Basic(t *testing.T) { + stores := newTestStores(t) + + seedPackage(t, stores, map[string]any{ + "id": "export-pkg", "title": "Export Test", "type": "surface", "version": "1.0.0", + }) + + router := setupUpdateRouter(t, stores) + + req := httptest.NewRequest("GET", "/api/v1/admin/packages/export-pkg/export", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify response is a valid zip with manifest.json + zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatal("response is not a valid zip:", err) + } + + foundManifest := false + for _, f := range zr.File { + if f.Name == "manifest.json" { + foundManifest = true + rc, _ := f.Open() + data, _ := io.ReadAll(rc) + rc.Close() + var m map[string]any + if json.Unmarshal(data, &m) != nil { + t.Fatal("manifest.json is not valid JSON") + } + if m["id"] != "export-pkg" { + t.Errorf("manifest id = %v, want export-pkg", m["id"]) + } + } + } + if !foundManifest { + t.Error("zip does not contain manifest.json") + } +} + +func TestExportPackage_NotFound(t *testing.T) { + stores := newTestStores(t) + router := setupUpdateRouter(t, stores) + + req := httptest.NewRequest("GET", "/api/v1/admin/packages/nonexistent/export", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +// TestBundledInstall_DefaultAllowlist verifies the curated default set behavior. +func TestBundledInstall_DefaultAllowlist(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + bundledDir := t.TempDir() + packagesDir := t.TempDir() + + // Create packages: one in default set, one not + buildTestPkg(t, bundledDir, map[string]any{ + "id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0", + }) + buildTestPkg(t, bundledDir, map[string]any{ + "id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0", + }) + + // Empty allowlist → curated defaults + InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) + + // "notes" is in the default set → should be installed + pkg, err := stores.Packages.Get(ctx, "notes") + if err != nil || pkg == nil { + t.Fatal("notes should be installed (in default set)") + } + + // "regex-tester" is NOT in the default set → should be filtered + pkg, _ = stores.Packages.Get(ctx, "regex-tester") + if pkg != nil { + t.Fatal("regex-tester should NOT be installed (not in default set)") + } +} + +// TestBundledInstall_WildcardAllowlist verifies "*" installs everything. +func TestBundledInstall_WildcardAllowlist(t *testing.T) { + stores := newTestStores(t) + ctx := context.Background() + + bundledDir := t.TempDir() + packagesDir := t.TempDir() + + buildTestPkg(t, bundledDir, map[string]any{ + "id": "any-pkg", "title": "Any", "type": "surface", "version": "1.0.0", + }) + + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + + pkg, err := stores.Packages.Get(ctx, "any-pkg") + if err != nil || pkg == nil { + t.Fatal("any-pkg should be installed with * wildcard") + } +} diff --git a/server/handlers/semver.go b/server/handlers/semver.go new file mode 100644 index 0000000..b4d5f6b --- /dev/null +++ b/server/handlers/semver.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "fmt" + "strconv" + "strings" +) + +// Semver represents a parsed semantic version (major.minor.patch[-prerelease]). +type Semver struct { + Major int + Minor int + Patch int + Prerelease string +} + +// ParseSemver parses a version string like "1.2.3" or "v1.2.3-beta.1". +// Leading "v" is stripped. Prerelease suffix after "-" is preserved. +func ParseSemver(s string) (Semver, error) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if s == "" { + return Semver{}, fmt.Errorf("empty version string") + } + + // Split off prerelease suffix + var pre string + if idx := strings.IndexByte(s, '-'); idx >= 0 { + pre = s[idx+1:] + s = s[:idx] + } + + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Semver{}, fmt.Errorf("version %q must have exactly 3 parts (major.minor.patch)", s) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return Semver{}, fmt.Errorf("invalid major version %q: %w", parts[0], err) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return Semver{}, fmt.Errorf("invalid minor version %q: %w", parts[1], err) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return Semver{}, fmt.Errorf("invalid patch version %q: %w", parts[2], err) + } + + return Semver{Major: major, Minor: minor, Patch: patch, Prerelease: pre}, nil +} + +// Compare returns -1 if a < b, 0 if a == b, +1 if a > b. +// Prerelease versions sort lower than the same release version (per semver spec). +func (a Semver) Compare(b Semver) int { + if a.Major != b.Major { + return cmpInt(a.Major, b.Major) + } + if a.Minor != b.Minor { + return cmpInt(a.Minor, b.Minor) + } + if a.Patch != b.Patch { + return cmpInt(a.Patch, b.Patch) + } + // Same numeric version — prerelease < release + if a.Prerelease == b.Prerelease { + return 0 + } + if a.Prerelease == "" { + return 1 // a is release, b has prerelease → a > b + } + if b.Prerelease == "" { + return -1 // a has prerelease, b is release → a < b + } + // Both have prerelease — compare lexicographically + if a.Prerelease < b.Prerelease { + return -1 + } + return 1 +} + +// String returns the version in "major.minor.patch[-prerelease]" format. +func (v Semver) String() string { + s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Prerelease != "" { + s += "-" + v.Prerelease + } + return s +} + +func cmpInt(a, b int) int { + if a < b { + return -1 + } + return 1 +} diff --git a/server/handlers/semver_test.go b/server/handlers/semver_test.go new file mode 100644 index 0000000..4ea9952 --- /dev/null +++ b/server/handlers/semver_test.go @@ -0,0 +1,92 @@ +package handlers + +import "testing" + +func TestParseSemver(t *testing.T) { + tests := []struct { + input string + want Semver + wantErr bool + }{ + {"1.2.3", Semver{1, 2, 3, ""}, false}, + {"v1.2.3", Semver{1, 2, 3, ""}, false}, + {"0.0.0", Semver{0, 0, 0, ""}, false}, + {"1.2.3-beta.1", Semver{1, 2, 3, "beta.1"}, false}, + {"v0.5.4-rc1", Semver{0, 5, 4, "rc1"}, false}, + {"10.20.30", Semver{10, 20, 30, ""}, false}, + {"", Semver{}, true}, + {"not-a-version", Semver{}, true}, + {"1.2", Semver{}, true}, + {"1.2.3.4", Semver{}, true}, + {"a.b.c", Semver{}, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseSemver(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSemver(%q) expected error, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("ParseSemver(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("ParseSemver(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestSemverCompare(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.0.0", "2.0.0", -1}, + {"2.0.0", "1.0.0", 1}, + {"1.1.0", "1.0.0", 1}, + {"1.0.0", "1.1.0", -1}, + {"1.0.1", "1.0.0", 1}, + {"1.0.0", "1.0.0", 0}, + {"1.0.0-alpha", "1.0.0", -1}, + {"1.0.0", "1.0.0-alpha", 1}, + {"1.0.0-alpha", "1.0.0-beta", -1}, + {"1.0.0-beta", "1.0.0-alpha", 1}, + {"1.0.0-alpha", "1.0.0-alpha", 0}, + {"0.5.3", "0.5.4", -1}, + {"0.5.4", "0.5.3", 1}, + } + + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + a, _ := ParseSemver(tt.a) + b, _ := ParseSemver(tt.b) + got := a.Compare(b) + if got != tt.want { + t.Errorf("(%s).Compare(%s) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestSemverString(t *testing.T) { + tests := []struct { + v Semver + want string + }{ + {Semver{1, 2, 3, ""}, "1.2.3"}, + {Semver{0, 0, 0, ""}, "0.0.0"}, + {Semver{1, 0, 0, "beta.1"}, "1.0.0-beta.1"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.v.String(); got != tt.want { + t.Errorf("Semver.String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/server/main.go b/server/main.go index 3d678f5..7fd31dd 100644 --- a/server/main.go +++ b/server/main.go @@ -737,6 +737,8 @@ func main() { admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2 admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2 admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2 + admin.POST("/packages/:id/update", pkgAdm.UpdatePackage) // v0.5.4 + admin.GET("/packages/:id/export", pkgAdm.ExportPackage) // v0.5.4 admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2 // Workflow package export (v0.30.2) diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index f13849c..7da25c7 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -253,6 +253,8 @@ export function createDomains(restClient) { updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data), dependencies: (id) => rc.get(`/api/v1/admin/packages/${id}/dependencies`), // v0.38.2 consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2 + update: (id, file) => rc.upload(`/api/v1/admin/packages/${id}/update`, file), // v0.5.4 + exportPkg: (id) => `/api/v1/admin/packages/${id}/export`, // v0.5.4 (URL for window.open) registry: () => rc.get('/api/v1/admin/packages/registry'), registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }), // v0.5.0: Extension permissions diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js index 2af4ef0..7a7b906 100644 --- a/src/js/sw/surfaces/admin/packages.js +++ b/src/js/sw/surfaces/admin/packages.js @@ -116,6 +116,27 @@ export default function PackagesSection() { window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank'); } + function updatePkg(pkg) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pkg,.surface,.zip'; + input.onchange = async () => { + const file = input.files[0]; + if (!file) return; + const ok = await sw.confirm(`Update "${pkg.title}" from v${pkg.version}?\n\nExport a backup first if needed.`, { destructive: true }); + if (!ok) return; + setInstalling(true); + try { + const res = await sw.api.admin.packages.update(pkg.id, file); + const data = res?.data || res || {}; + sw.toast(`Updated to v${data.version || '?'}`, 'success'); + load(); + } catch (e) { sw.toast(e.message, 'error'); } + finally { setInstalling(false); } + }; + input.click(); + } + // ── Settings drawer ───────────────────────── async function toggleSettings(pkgId) { if (expandedId === pkgId) { @@ -271,6 +292,9 @@ export default function PackagesSection() { ${permsId === pkg.id ? 'Close' : 'Permissions'} `} + ${pkg.source !== 'core' && html` + + `} ${pkg.source !== 'core' && !pkg.is_system && html`