package handlers import ( "context" "encoding/json" "log" "os" "path/filepath" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // builtinManifest is the on-disk manifest.json shape. type builtinManifest struct { ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Tier string `json:"tier"` Author string `json:"author"` Description string `json:"description"` Permissions json.RawMessage `json:"permissions"` Tools json.RawMessage `json:"tools"` Surfaces json.RawMessage `json:"surfaces"` Settings json.RawMessage `json:"settings"` } // SeedBuiltinExtensions scans a directory for builtin extension bundles // (each subdirectory contains manifest.json + script.js) and upserts them // into the extensions table as system extensions. // // Layout: // // extensions/builtin/ // mermaid-renderer/ // manifest.json // script.js // // Idempotent: skips extensions whose version matches what's already installed, // updates those with a newer version, creates new ones. func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) { if stores.Extensions == nil { return } entries, err := os.ReadDir(extensionsDir) if err != nil { if os.IsNotExist(err) { log.Printf(" 📦 No builtin extensions directory at %s (skipping)", extensionsDir) return } log.Printf("⚠ Failed to read extensions directory %s: %v", extensionsDir, err) return } ctx := context.Background() seeded, updated, skipped := 0, 0, 0 for _, entry := range entries { if !entry.IsDir() { continue } extDir := filepath.Join(extensionsDir, entry.Name()) manifestPath := filepath.Join(extDir, "manifest.json") scriptPath := filepath.Join(extDir, "script.js") // Read manifest manifestData, err := os.ReadFile(manifestPath) if err != nil { log.Printf("⚠ Skipping %s: no manifest.json: %v", entry.Name(), err) continue } var manifest builtinManifest if err := json.Unmarshal(manifestData, &manifest); err != nil { log.Printf("⚠ Skipping %s: invalid manifest.json: %v", entry.Name(), err) continue } if manifest.ID == "" { log.Printf("⚠ Skipping %s: manifest missing 'id'", entry.Name()) continue } // Read script (optional — some extensions might be manifest-only) var script string if scriptData, err := os.ReadFile(scriptPath); err == nil { script = string(scriptData) } // Build the full manifest JSONB with _script inlined fullManifest := buildFullManifest(manifestData, script) // Check if already installed existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID) if err == nil { // Already exists — check version if existing.Version == manifest.Version { skipped++ continue } // Version changed — update existing.Name = manifest.Name existing.Version = manifest.Version existing.Description = manifest.Description existing.Author = manifest.Author existing.Manifest = fullManifest existing.IsSystem = true existing.IsEnabled = true if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil { log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err) continue } updated++ continue } // New extension — create tier := manifest.Tier if tier == "" { tier = models.ExtTierBrowser } ext := &models.Extension{ ExtID: manifest.ID, Name: manifest.Name, Version: manifest.Version, Tier: tier, Description: manifest.Description, Author: manifest.Author, Manifest: fullManifest, IsSystem: true, IsEnabled: true, Scope: "global", } if err := stores.Extensions.Create(ctx, ext); err != nil { log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err) continue } seeded++ } if seeded+updated > 0 { log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped) } else if skipped > 0 { log.Printf(" 📦 Builtin extensions: %d up to date", skipped) } } // buildFullManifest merges the raw manifest JSON with an inline _script field. func buildFullManifest(rawManifest []byte, script string) json.RawMessage { if script == "" { return json.RawMessage(rawManifest) } // Parse manifest as generic map, add _script, re-serialize var m map[string]json.RawMessage if err := json.Unmarshal(rawManifest, &m); err != nil { // Fallback: just return raw manifest return json.RawMessage(rawManifest) } scriptJSON, err := json.Marshal(script) if err != nil { return json.RawMessage(rawManifest) } m["_script"] = json.RawMessage(scriptJSON) result, err := json.Marshal(m) if err != nil { return json.RawMessage(rawManifest) } return json.RawMessage(result) }