Feat v0.5.4 package updates (#34)
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 #34.
This commit is contained in:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
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
|
## v0.5.3 — Chat Polish + Integration Testing
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
15
ROADMAP.md
15
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. |
|
| "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%). |
|
| 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 |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Version comparison | | Compare installed package version against incoming `.pkg` version. Semantic version parsing. Skip if same or older. |
|
| Curate bundled packages | ✅ | Default set reduced from 23 to 8 core packages. `BUNDLED_PACKAGES=*` for all; explicit list for custom. |
|
||||||
| 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). |
|
| Version comparison | ✅ | Semver parsing (`ParseSemver`) with prerelease support. Version bump validated on update. |
|
||||||
| Data preservation | | ext_data tables survive update. Only additive schema changes applied. Settings merged (new keys added, existing preserved). |
|
| Schema migration on update | ✅ | `MigrateExtTables` diffs `db_tables` against existing schema. Adds new columns/tables. No destructive changes. |
|
||||||
| Update API | | `PUT /api/v1/packages/:id/update` — accepts `.pkg` archive, validates version bump, applies schema diff, replaces code (JS/CSS/Starlark). |
|
| Data preservation | ✅ | ext_data tables survive update. Settings merged (new keys added with defaults, existing preserved). |
|
||||||
| Admin UI | | Update button on package card when newer version available. Version comparison badge. Update confirmation via `sw.ui.Confirm`. |
|
| Update API | ✅ | `POST /api/v1/admin/packages/:id/update` — accepts `.pkg` archive, validates version bump, applies schema diff, replaces code. |
|
||||||
| Rollback story | | Export package before update (existing export). Document manual rollback: re-install old `.pkg`. No automatic rollback — KISS. |
|
| 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)
|
### v0.5.5 — Upgrade Testing (planned)
|
||||||
|
|
||||||
|
|||||||
@@ -14,42 +14,66 @@ On first run, bundled packages are automatically installed — workflows, surfac
|
|||||||
|
|
||||||
## Bundled Packages
|
## 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 |
|
| 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 |
|
| bug-report-triage | workflow | Public entry, severity routing, SLA timers |
|
||||||
| content-approval | workflow | Multi-party signoff example |
|
| content-approval | workflow | Multi-party signoff example |
|
||||||
| employee-onboarding | workflow | Automated provisioning + manager signoff |
|
| employee-onboarding | workflow | Automated provisioning + manager signoff |
|
||||||
| webhook-notifier | workflow | HTTP outbound via connections + Starlark |
|
|
||||||
| workflow-demo | surface | Interactive walkthrough with diagrams |
|
#### Opt-in (ship in image, require `BUNDLED_PACKAGES` to enable)
|
||||||
| hello-dashboard | surface | Welcome/getting started surface |
|
|
||||||
| schedules | surface | Cron task management UI |
|
| Package | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
| tasks | full | Kanban/list task manager with webhooks |
|
| 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 |
|
| 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 |
|
| 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 |
|
| icd-test-runner | surface | E2E API test suite |
|
||||||
| sdk-test-runner | surface | SDK feature test suite |
|
| sdk-test-runner | surface | SDK feature test suite |
|
||||||
|
|
||||||
### Behavior
|
### 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.
|
- **Subsequent boots**: No-op — already-installed packages are skipped.
|
||||||
- **Admin uninstalls a package**: It stays uninstalled. Bundled packages are never force-reinstalled.
|
- **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.
|
- **To re-install**: Delete the package from the database, then restart the container.
|
||||||
|
|
||||||
### Selecting Packages (Allowlist)
|
### 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
|
```bash
|
||||||
# K8s / Helm — install only core surfaces, no demo/test packages
|
# Install ALL packages (everything in the image)
|
||||||
docker run -p 8080:80 \
|
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
|
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
|
### Disabling Auto-Install
|
||||||
|
|
||||||
@@ -149,7 +173,7 @@ docker build -t my-switchboard .
|
|||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `BASE_PATH` | | URL prefix (e.g. `/switchboard`) |
|
| `BASE_PATH` | | URL prefix (e.g. `/switchboard`) |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
| `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 |
|
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
||||||
| `LOG_FORMAT` | `text` | `text` or `json` |
|
| `LOG_FORMAT` | `text` | `text` or `json` |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ type Config struct {
|
|||||||
MTLSAutoActivate bool // auto-activate new users (default true)
|
MTLSAutoActivate bool // auto-activate new users (default true)
|
||||||
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
|
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.
|
// 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_DIR: directory containing pre-built .pkg archives (default /app/bundled-packages).
|
||||||
// BUNDLED_PACKAGES: comma-separated allowlist of package IDs to install (empty = all).
|
// BUNDLED_PACKAGES: controls which bundled packages are auto-installed:
|
||||||
// e.g. "tasks,schedules,hello-dashboard" installs only those three.
|
// 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
|
SkipBundledPackages bool
|
||||||
BundledPackagesDir string
|
BundledPackagesDir string
|
||||||
BundledPackages string
|
BundledPackages string
|
||||||
|
|||||||
@@ -186,6 +186,130 @@ func CreateExtTables(
|
|||||||
return nil
|
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
|
// DropExtTables drops all physical tables registered for a package and removes
|
||||||
// their entries from the ext_data_tables catalog.
|
// their entries from the ext_data_tables catalog.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -897,3 +897,343 @@ func (h *PackageHandler) TestTool(c *gin.Context) {
|
|||||||
"output": output,
|
"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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,13 +22,28 @@ import (
|
|||||||
"switchboard-core/triggers"
|
"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
|
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
||||||
// any that don't already exist in the database. Called once at startup
|
// any that don't already exist in the database. Called once at startup
|
||||||
// (unless SKIP_BUNDLED_PACKAGES=true).
|
// (unless SKIP_BUNDLED_PACKAGES=true).
|
||||||
//
|
//
|
||||||
// allowlist is an optional comma-separated list of package IDs to install.
|
// allowlist controls which packages are installed:
|
||||||
// Empty string means install all. This allows Helm/K8s deployments to
|
// - Empty string → install the curated default set (see defaultBundledPackages)
|
||||||
// select a subset: e.g. "tasks,schedules,hello-dashboard".
|
// - "*" → 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
|
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
|
||||||
// package, it won't be re-installed on the next restart.
|
// package, it won't be re-installed on the next restart.
|
||||||
@@ -43,10 +58,14 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse allowlist into a set (empty = allow all)
|
// Parse allowlist: "" → curated defaults, "*" → all, "a,b" → explicit list
|
||||||
allowed := parseBundleAllowlist(allowlist)
|
allowed := parseBundleAllowlist(allowlist)
|
||||||
if len(allowed) > 0 {
|
if allowed != nil {
|
||||||
log.Printf("[bundled] Allowlist: %v", allowlist)
|
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
|
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
|
// 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 {
|
func parseBundleAllowlist(s string) map[string]bool {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
return defaultBundledPackages
|
||||||
|
}
|
||||||
|
if s == "*" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, ",")
|
parts := strings.Split(s, ",")
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestBundledInstall_FreshDB(t *testing.T) {
|
|||||||
"route": "/s/test-b",
|
"route": "/s/test-b",
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
||||||
|
|
||||||
// Both packages should be installed and enabled
|
// Both packages should be installed and enabled
|
||||||
for _, id := range []string{"test-surface-a", "test-surface-b"} {
|
for _, id := range []string{"test-surface-a", "test-surface-b"} {
|
||||||
@@ -127,7 +127,7 @@ func TestBundledInstall_SkipsExisting(t *testing.T) {
|
|||||||
"type": "surface",
|
"type": "surface",
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
||||||
|
|
||||||
// Package should retain original title (not overwritten)
|
// Package should retain original title (not overwritten)
|
||||||
pkg, err := stores.Packages.Get(ctx, "pre-existing")
|
pkg, err := stores.Packages.Get(ctx, "pre-existing")
|
||||||
@@ -174,7 +174,7 @@ func TestBundledInstall_DormantPackage(t *testing.T) {
|
|||||||
"requires": []string{"chat"},
|
"requires": []string{"chat"},
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
||||||
|
|
||||||
pkg, err := stores.Packages.Get(ctx, "dormant-pkg")
|
pkg, err := stores.Packages.Get(ctx, "dormant-pkg")
|
||||||
if err != nil || pkg == nil {
|
if err != nil || pkg == nil {
|
||||||
|
|||||||
494
server/handlers/packages_update_test.go
Normal file
494
server/handlers/packages_update_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
97
server/handlers/semver.go
Normal file
97
server/handlers/semver.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
92
server/handlers/semver_test.go
Normal file
92
server/handlers/semver_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -737,6 +737,8 @@ func main() {
|
|||||||
admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2
|
admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2
|
||||||
admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // 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/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
|
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
|
||||||
|
|
||||||
// Workflow package export (v0.30.2)
|
// Workflow package export (v0.30.2)
|
||||||
|
|||||||
@@ -253,6 +253,8 @@ export function createDomains(restClient) {
|
|||||||
updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data),
|
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
|
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
|
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'),
|
registry: () => rc.get('/api/v1/admin/packages/registry'),
|
||||||
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }),
|
||||||
// v0.5.0: Extension permissions
|
// v0.5.0: Extension permissions
|
||||||
|
|||||||
@@ -116,6 +116,27 @@ export default function PackagesSection() {
|
|||||||
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
|
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 ─────────────────────────
|
// ── Settings drawer ─────────────────────────
|
||||||
async function toggleSettings(pkgId) {
|
async function toggleSettings(pkgId) {
|
||||||
if (expandedId === pkgId) {
|
if (expandedId === pkgId) {
|
||||||
@@ -271,6 +292,9 @@ export default function PackagesSection() {
|
|||||||
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
${permsId === pkg.id ? 'Close' : 'Permissions'}
|
||||||
</button>
|
</button>
|
||||||
`}
|
`}
|
||||||
|
${pkg.source !== 'core' && html`
|
||||||
|
<button class="btn-small" onClick=${() => updatePkg(pkg)}>Update</button>
|
||||||
|
`}
|
||||||
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
|
||||||
${pkg.source !== 'core' && !pkg.is_system && html`
|
${pkg.source !== 'core' && !pkg.is_system && html`
|
||||||
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user