first commit
This commit is contained in:
@@ -2,27 +2,43 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListExtensions reads the extensions directory and returns metadata.
|
||||
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 {
|
||||
extDir := filepath.Join(agentDir, "extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
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(extDir, e.Name()), e.Name())
|
||||
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
|
||||
@@ -30,13 +46,11 @@ func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||
|
||||
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info := models.ExtensionInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Enabled: true, // assume enabled unless configured otherwise
|
||||
Source: "local",
|
||||
Name: id,
|
||||
Path: path,
|
||||
}
|
||||
|
||||
// try package.json for version/name
|
||||
// try package.json for name/version
|
||||
pkgPath := filepath.Join(path, "package.json")
|
||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||
var pkg struct {
|
||||
@@ -48,58 +62,149 @@ func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info.Name = pkg.Name
|
||||
}
|
||||
info.Version = pkg.Version
|
||||
// detect npm packages
|
||||
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
|
||||
info.Source = "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ToggleExtension is a placeholder – full implementation requires settings.json management.
|
||||
func ToggleExtension(agentDir, id string, enable bool) error {
|
||||
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
|
||||
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 updateSettingsExtensions(settingsPath, id string, enable bool) error {
|
||||
func readNpmPackageSources(settingsPath string) []string {
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
|
||||
// extensions is a list of enabled extension IDs
|
||||
extList, _ := settings["extensions"].([]any)
|
||||
set := map[string]bool{}
|
||||
for _, e := range extList {
|
||||
if s, ok := e.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
set[id] = true
|
||||
} else {
|
||||
delete(set, id)
|
||||
}
|
||||
|
||||
newList := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
settings["extensions"] = newList
|
||||
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
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.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user