Files
sproutclaw-web/backend/internal/services/extensions.go
2026-06-15 13:38:23 +08:00

212 lines
5.3 KiB
Go

package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sproutclaw-web/internal/models"
"sproutclaw-web/internal/utils"
)
var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`)
// ListExtensions returns local extensions (enabled and disabled) plus npm packages
// declared in settings.json. Local ones live in extensions/ and extensions-disabled/.
func ListExtensions(agentDir string) []models.ExtensionInfo {
var exts []models.ExtensionInfo
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions"), true)...)
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions-disabled"), false)...)
exts = append(exts, listNpmExtensions(agentDir)...)
return exts
}
func scanExtensionsDir(dir string, enabled bool) []models.ExtensionInfo {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var exts []models.ExtensionInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
ext := buildExtensionInfo(filepath.Join(dir, e.Name()), e.Name())
// Anything physically under extensions/ or extensions-disabled/ is a
// local extension and can be toggled, regardless of its package.json name.
ext.Category = "local"
ext.Toggleable = true
ext.Enabled = enabled
exts = append(exts, ext)
}
return exts
}
func buildExtensionInfo(path, id string) models.ExtensionInfo {
info := models.ExtensionInfo{
Name: id,
Path: path,
}
// try package.json for name/version
pkgPath := filepath.Join(path, "package.json")
if data, err := os.ReadFile(pkgPath); err == nil {
var pkg struct {
Name string `json:"name"`
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) == nil {
if pkg.Name != "" {
info.Name = pkg.Name
}
info.Version = pkg.Version
}
}
return info
}
func listNpmExtensions(agentDir string) []models.ExtensionInfo {
sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json"))
if len(sources) == 0 {
return nil
}
seen := make(map[string]bool, len(sources))
var exts []models.ExtensionInfo
for _, source := range sources {
name, ok := npmPackageName(source)
if !ok || seen[name] {
continue
}
seen[name] = true
exts = append(exts, buildNpmExtensionInfo(agentDir, name, source))
}
return exts
}
func readNpmPackageSources(settingsPath string) []string {
data, err := os.ReadFile(settingsPath)
if err != nil {
return nil
}
var settings struct {
Packages []json.RawMessage `json:"packages"`
Extensions []string `json:"extensions"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return nil
}
var sources []string
for _, raw := range settings.Packages {
if source := parsePackageSource(raw); source != "" {
sources = append(sources, source)
}
}
for _, source := range settings.Extensions {
if strings.HasPrefix(source, "npm:") {
sources = append(sources, source)
}
}
return sources
}
func parsePackageSource(raw json.RawMessage) string {
var source string
if err := json.Unmarshal(raw, &source); err == nil {
return strings.TrimSpace(source)
}
var obj struct {
Source string `json:"source"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
return strings.TrimSpace(obj.Source)
}
return ""
}
func npmPackageName(source string) (string, bool) {
if !strings.HasPrefix(source, "npm:") {
return "", false
}
spec := strings.TrimSpace(strings.TrimPrefix(source, "npm:"))
if spec == "" {
return "", false
}
name, _ := parseNpmSpec(spec)
if name == "" {
return "", false
}
return name, true
}
func parseNpmSpec(spec string) (name, version string) {
match := npmSpecRe.FindStringSubmatch(spec)
if match == nil {
return spec, ""
}
name = match[1]
version = match[2]
return name, version
}
func buildNpmExtensionInfo(agentDir, packageName, source string) models.ExtensionInfo {
modPath := filepath.Join(agentDir, "npm", "node_modules", packageName)
info := models.ExtensionInfo{
Name: packageName,
Path: modPath,
Source: source,
Category: "npm",
Enabled: true,
Toggleable: false,
}
pkgPath := filepath.Join(modPath, "package.json")
if data, err := os.ReadFile(pkgPath); err == nil {
var pkg struct {
Name string `json:"name"`
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) == nil {
if pkg.Name != "" {
info.Name = pkg.Name
}
info.Version = pkg.Version
}
}
return info
}
// ToggleExtension moves a local extension between extensions/ and
// extensions-disabled/ directories. The agent's loader only scans extensions/,
// so moving a directory out of it disables that extension. npm extensions live
// elsewhere and are not managed here.
func ToggleExtension(agentDir, extPath string, enable bool) error {
extDir := filepath.Join(agentDir, "extensions")
disabledDir := filepath.Join(agentDir, "extensions-disabled")
if !isUnderDir(extPath, extDir) && !isUnderDir(extPath, disabledDir) {
return fmt.Errorf("extension path not in managed directories")
}
name := filepath.Base(extPath)
var src, dst string
if enable {
src = filepath.Join(disabledDir, name)
dst = filepath.Join(extDir, name)
} else {
src = filepath.Join(extDir, name)
dst = filepath.Join(disabledDir, name)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return utils.Move(src, dst)
}