211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"sproutclaw-web/internal/models"
|
|
)
|
|
|
|
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 os.Rename(src, dst)
|
|
}
|