Review follow-up on #126: RemovePlugin used to call setPluginEnabled(name, false), which left the plugin's key in EnabledPlugins and never touched PluginConfigs. settings.json retained dangling state for plugins no longer on disk. Replace the disable with purgePluginFromSettings, which deletes both the EnabledPlugins entry and any PluginConfigs entry for the removed plugin, leaving unrelated plugins' state intact. A re-install defaults to enabled=true via the existing install paths, matching user expectations. Covered by TestRemovePluginPurgesSettings across both user and legacy managed layouts.
941 lines
27 KiB
Go
941 lines
27 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// Loader scans plugin directories and returns loaded, validated plugins.
|
|
type Loader struct {
|
|
// PluginsDir is the root directory for all plugins.
|
|
// Defaults to ~/.dws/plugins/.
|
|
PluginsDir string
|
|
|
|
// CLIVersion is the current CLI version, used for
|
|
// minCLIVersion compatibility checks.
|
|
CLIVersion string
|
|
}
|
|
|
|
// NewLoader creates a Loader with default paths.
|
|
func NewLoader(cliVersion string) *Loader {
|
|
home, _ := os.UserHomeDir()
|
|
return &Loader{
|
|
PluginsDir: filepath.Join(home, ".dws", "plugins"),
|
|
CLIVersion: cliVersion,
|
|
}
|
|
}
|
|
|
|
// Settings holds user preferences for plugin management.
|
|
type Settings struct {
|
|
EnabledPlugins map[string]bool `json:"enabledPlugins,omitempty"`
|
|
PluginConfigs map[string]map[string]any `json:"pluginConfigs,omitempty"`
|
|
PluginAutoUpdate bool `json:"pluginAutoUpdate,omitempty"`
|
|
DevPlugins map[string]string `json:"devPlugins,omitempty"` // name → absolute path
|
|
}
|
|
|
|
// LoadManaged scans ~/.dws/plugins/managed/ and returns all valid
|
|
// official plugins. Managed plugins are always enabled.
|
|
func (l *Loader) LoadManaged() []*Plugin {
|
|
managedDir := filepath.Join(l.PluginsDir, "managed")
|
|
return l.scanDir(managedDir, true)
|
|
}
|
|
|
|
// LoadUser scans ~/.dws/plugins/user/ and returns enabled user plugins.
|
|
func (l *Loader) LoadUser() []*Plugin {
|
|
userDir := filepath.Join(l.PluginsDir, "user")
|
|
settings := l.loadSettings()
|
|
|
|
var plugins []*Plugin
|
|
// User plugins may be nested: user/{workspace}/{name}/
|
|
entries, err := os.ReadDir(userDir)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
slog.Debug("plugin: cannot read user dir", "path", userDir, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
entryPath := filepath.Join(userDir, entry.Name())
|
|
|
|
// Check if this is a direct plugin directory (has plugin.json)
|
|
if _, err := os.Stat(filepath.Join(entryPath, "plugin.json")); err == nil {
|
|
p := l.loadPlugin(entryPath, false)
|
|
if p != nil && isPluginEnabled(settings, p.Manifest.Name) {
|
|
plugins = append(plugins, p)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Otherwise treat as workspace directory: user/{workspace}/{name}/
|
|
subEntries, err := os.ReadDir(entryPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, sub := range subEntries {
|
|
if !sub.IsDir() {
|
|
continue
|
|
}
|
|
subPath := filepath.Join(entryPath, sub.Name())
|
|
p := l.loadPlugin(subPath, false)
|
|
if p != nil {
|
|
qualifiedName := entry.Name() + "/" + p.Manifest.Name
|
|
if isPluginEnabled(settings, qualifiedName) {
|
|
plugins = append(plugins, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// LoadAll loads both managed and user plugins.
|
|
func (l *Loader) LoadAll() []*Plugin {
|
|
managed := l.LoadManaged()
|
|
user := l.LoadUser()
|
|
return append(managed, user...)
|
|
}
|
|
|
|
// scanDir reads a directory of plugin subdirectories and loads each one.
|
|
func (l *Loader) scanDir(dir string, isManaged bool) []*Plugin {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
slog.Debug("plugin: cannot read dir", "path", dir, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var plugins []*Plugin
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
pluginDir := filepath.Join(dir, entry.Name())
|
|
p := l.loadPlugin(pluginDir, isManaged)
|
|
if p != nil {
|
|
plugins = append(plugins, p)
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// loadPlugin reads and validates a single plugin directory.
|
|
func (l *Loader) loadPlugin(dir string, isManaged bool) *Plugin {
|
|
manifestPath := filepath.Join(dir, "plugin.json")
|
|
manifest, err := ParseManifest(manifestPath)
|
|
if err != nil {
|
|
slog.Warn("plugin: failed to parse manifest",
|
|
"path", manifestPath, "error", err)
|
|
return nil
|
|
}
|
|
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
slog.Warn("plugin: validation failed",
|
|
"plugin", manifest.Name, "error", err)
|
|
return nil
|
|
}
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: dir,
|
|
IsManaged: isManaged,
|
|
}
|
|
}
|
|
|
|
// settingsPath returns the path to settings.json.
|
|
// Uses PluginsDir's parent (~/.dws/) for production, PluginsDir itself for tests.
|
|
func (l *Loader) settingsPath() string {
|
|
// If PluginsDir ends with "plugins", go up one level to ~/.dws/
|
|
if filepath.Base(l.PluginsDir) == "plugins" {
|
|
return filepath.Join(filepath.Dir(l.PluginsDir), "settings.json")
|
|
}
|
|
// For test temp dirs, use PluginsDir directly
|
|
return filepath.Join(l.PluginsDir, "settings.json")
|
|
}
|
|
|
|
// loadSettings reads settings.json from the parent of PluginsDir.
|
|
func (l *Loader) loadSettings() *Settings {
|
|
settingsPath := l.settingsPath()
|
|
data, err := os.ReadFile(settingsPath)
|
|
if err != nil {
|
|
return &Settings{}
|
|
}
|
|
var s Settings
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
slog.Debug("plugin: failed to parse settings.json", "error", err)
|
|
return &Settings{}
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func isPluginEnabled(s *Settings, name string) bool {
|
|
if s == nil || s.EnabledPlugins == nil {
|
|
return true // default: enabled
|
|
}
|
|
enabled, exists := s.EnabledPlugins[name]
|
|
if !exists {
|
|
return true // not in list = enabled
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
// InstalledPlugins returns the list of all installed plugins with their
|
|
// status info. Used by `dws plugin list`.
|
|
type PluginInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Type string `json:"type"` // "managed" or "user"
|
|
Enabled bool `json:"enabled"`
|
|
Path string `json:"path"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ListInstalled returns info about all installed plugins.
|
|
func (l *Loader) ListInstalled() []PluginInfo {
|
|
var result []PluginInfo
|
|
settings := l.loadSettings()
|
|
|
|
// Managed plugins
|
|
managedDir := filepath.Join(l.PluginsDir, "managed")
|
|
if entries, err := os.ReadDir(managedDir); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
dir := filepath.Join(managedDir, entry.Name())
|
|
m, err := ParseManifest(filepath.Join(dir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, PluginInfo{
|
|
Name: m.Name,
|
|
Version: m.Version,
|
|
Type: "managed",
|
|
Enabled: true, // managed plugins always enabled
|
|
Path: dir,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
}
|
|
|
|
// User plugins
|
|
userDir := filepath.Join(l.PluginsDir, "user")
|
|
if entries, err := os.ReadDir(userDir); err == nil {
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
l.collectUserPluginInfos(filepath.Join(userDir, entry.Name()), entry.Name(), settings, &result)
|
|
}
|
|
}
|
|
|
|
// Dev plugins
|
|
for name, dir := range settings.DevPlugins {
|
|
m, err := ParseManifest(filepath.Join(dir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, PluginInfo{
|
|
Name: name,
|
|
Version: m.Version,
|
|
Type: "dev",
|
|
Enabled: true,
|
|
Path: dir,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (l *Loader) collectUserPluginInfos(dir, prefix string, settings *Settings, result *[]PluginInfo) {
|
|
// Direct plugin
|
|
if m, err := ParseManifest(filepath.Join(dir, "plugin.json")); err == nil {
|
|
qualName := prefix
|
|
*result = append(*result, PluginInfo{
|
|
Name: qualName,
|
|
Version: m.Version,
|
|
Type: "user",
|
|
Enabled: isPluginEnabled(settings, qualName),
|
|
Path: dir,
|
|
Description: m.Description,
|
|
})
|
|
return
|
|
}
|
|
// Workspace: dir is a workspace, iterate sub-plugins
|
|
subEntries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, sub := range subEntries {
|
|
if !sub.IsDir() {
|
|
continue
|
|
}
|
|
subDir := filepath.Join(dir, sub.Name())
|
|
m, err := ParseManifest(filepath.Join(subDir, "plugin.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
qualName := prefix + "/" + m.Name
|
|
*result = append(*result, PluginInfo{
|
|
Name: qualName,
|
|
Version: m.Version,
|
|
Type: "user",
|
|
Enabled: isPluginEnabled(settings, qualName),
|
|
Path: subDir,
|
|
Description: m.Description,
|
|
})
|
|
}
|
|
}
|
|
|
|
// InstallFromDir copies a plugin from a source directory to the user
|
|
// plugins directory.
|
|
func (l *Loader) InstallFromDir(srcDir string) (*Plugin, error) {
|
|
manifestPath := filepath.Join(srcDir, "plugin.json")
|
|
manifest, err := ParseManifest(manifestPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid plugin: %w", err)
|
|
}
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
return nil, fmt.Errorf("plugin validation failed: %w", err)
|
|
}
|
|
|
|
destDir := filepath.Join(l.PluginsDir, "user", manifest.Name)
|
|
if err := copyDir(srcDir, destDir); err != nil {
|
|
return nil, fmt.Errorf("install failed: %w", err)
|
|
}
|
|
|
|
// Remove stale files in destDir that no longer exist in srcDir.
|
|
removeStaleFiles(srcDir, destDir)
|
|
|
|
// Run build if configured (compile server to binary).
|
|
if manifest.Build != nil {
|
|
if err := runBuild(destDir, manifest.Build); err != nil {
|
|
// Clean up on build failure.
|
|
_ = os.RemoveAll(destDir)
|
|
return nil, fmt.Errorf("plugin build failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Enable by default in settings
|
|
l.setPluginEnabled(manifest.Name, true)
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: destDir,
|
|
IsManaged: false,
|
|
}, nil
|
|
}
|
|
|
|
// InstallFromGit clones a git repository and installs the plugin.
|
|
// The workspace is extracted from the git URL (e.g. github.com/{workspace}/{name}).
|
|
func (l *Loader) InstallFromGit(gitURL string) (*Plugin, error) {
|
|
workspace, repoName, err := parseGitURL(gitURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid git URL: %w", err)
|
|
}
|
|
|
|
// Clone to temp directory.
|
|
tmpDir, err := os.MkdirTemp("", "dws-plugin-git-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cloneDir := filepath.Join(tmpDir, repoName)
|
|
cmd := exec.Command("git", "clone", "--depth", "1", gitURL, cloneDir)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("git clone failed: %w", err)
|
|
}
|
|
|
|
// Parse and validate manifest.
|
|
manifest, err := ParseManifest(filepath.Join(cloneDir, "plugin.json"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid plugin: %w", err)
|
|
}
|
|
if err := manifest.Validate(l.CLIVersion); err != nil {
|
|
return nil, fmt.Errorf("plugin validation failed: %w", err)
|
|
}
|
|
|
|
// All plugins install to the user directory with workspace nesting:
|
|
// ~/.dws/plugins/user/{workspace}/{name}/. There is no privileged
|
|
// workspace — every plugin is third-party.
|
|
destDir := filepath.Join(l.PluginsDir, config.PluginUserDir, workspace, manifest.Name)
|
|
|
|
// Remove .git directory before copying.
|
|
_ = os.RemoveAll(filepath.Join(cloneDir, ".git"))
|
|
|
|
if err := copyDir(cloneDir, destDir); err != nil {
|
|
return nil, fmt.Errorf("install failed: %w", err)
|
|
}
|
|
|
|
// Run build if configured (compile server to binary).
|
|
if manifest.Build != nil {
|
|
if err := runBuild(destDir, manifest.Build); err != nil {
|
|
// Clean up on build failure.
|
|
_ = os.RemoveAll(destDir)
|
|
return nil, fmt.Errorf("plugin build failed: %w", err)
|
|
}
|
|
}
|
|
|
|
qualifiedName := workspace + "/" + manifest.Name
|
|
l.setPluginEnabled(qualifiedName, true)
|
|
|
|
return &Plugin{
|
|
Manifest: *manifest,
|
|
Root: destDir,
|
|
IsManaged: false,
|
|
}, nil
|
|
}
|
|
|
|
// parseGitURL extracts workspace and repo name from a git URL.
|
|
// Supports: https://github.com/org/repo.git, git@github.com:org/repo.git
|
|
// Rejects file:// and other local protocols to prevent reading local files.
|
|
func parseGitURL(gitURL string) (workspace, repoName string, err error) {
|
|
gitURL = strings.TrimSpace(gitURL)
|
|
|
|
// Reject dangerous protocols that could read local files.
|
|
lower := strings.ToLower(gitURL)
|
|
if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, ".") {
|
|
return "", "", fmt.Errorf("local paths and file:// URLs are not allowed: %q", gitURL)
|
|
}
|
|
|
|
// Handle SSH format: git@github.com:org/repo.git
|
|
if strings.HasPrefix(gitURL, "git@") {
|
|
parts := strings.SplitN(gitURL, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("cannot parse SSH URL %q", gitURL)
|
|
}
|
|
path := strings.TrimSuffix(parts[1], ".git")
|
|
segments := strings.Split(path, "/")
|
|
if len(segments) < 2 {
|
|
return "", "", fmt.Errorf("SSH URL %q must have org/repo format", gitURL)
|
|
}
|
|
return segments[len(segments)-2], segments[len(segments)-1], nil
|
|
}
|
|
|
|
// Handle HTTPS format.
|
|
u, err := url.Parse(gitURL)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("cannot parse URL %q: %w", gitURL, err)
|
|
}
|
|
|
|
// Only allow https:// and http:// schemes.
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return "", "", fmt.Errorf("unsupported URL scheme %q: only https and ssh are allowed", u.Scheme)
|
|
}
|
|
|
|
path := strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git")
|
|
segments := strings.Split(path, "/")
|
|
if len(segments) < 2 {
|
|
return "", "", fmt.Errorf("URL %q must have org/repo format", gitURL)
|
|
}
|
|
|
|
return segments[len(segments)-2], segments[len(segments)-1], nil
|
|
}
|
|
|
|
// RemovePlugin removes an installed plugin by name. It searches both the
|
|
// user and the legacy managed directories; all plugins are equally
|
|
// removable.
|
|
func (l *Loader) RemovePlugin(name string, keepData bool) error {
|
|
pluginDir := l.findUserPluginDir(name)
|
|
if pluginDir == "" {
|
|
// Fall back to legacy managed/ directory (for plugins installed
|
|
// by older CLI builds that wrote under ~/.dws/plugins/managed/).
|
|
legacyDir := filepath.Join(l.PluginsDir, config.PluginManagedDir, name)
|
|
if _, err := os.Stat(filepath.Join(legacyDir, "plugin.json")); err == nil {
|
|
pluginDir = legacyDir
|
|
}
|
|
}
|
|
if pluginDir == "" {
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
|
|
if err := os.RemoveAll(pluginDir); err != nil {
|
|
return fmt.Errorf("failed to remove plugin: %w", err)
|
|
}
|
|
|
|
if !keepData {
|
|
dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name)
|
|
_ = os.RemoveAll(dataDir)
|
|
}
|
|
|
|
l.purgePluginFromSettings(name)
|
|
return nil
|
|
}
|
|
|
|
// purgePluginFromSettings removes all traces of a plugin from settings.json:
|
|
// its enabled flag and any persisted pluginConfigs entry. Called after
|
|
// RemovePlugin succeeds so settings.json does not retain dangling state for
|
|
// plugins that no longer exist on disk.
|
|
func (l *Loader) purgePluginFromSettings(name string) {
|
|
settings := l.loadSettings()
|
|
changed := false
|
|
if _, ok := settings.EnabledPlugins[name]; ok {
|
|
delete(settings.EnabledPlugins, name)
|
|
changed = true
|
|
}
|
|
if _, ok := settings.PluginConfigs[name]; ok {
|
|
delete(settings.PluginConfigs, name)
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return
|
|
}
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
// SetEnabled enables or disables a plugin in settings.json.
|
|
func (l *Loader) SetEnabled(name string, enabled bool) error {
|
|
// Verify plugin exists
|
|
if l.findUserPluginDir(name) == "" {
|
|
managedDir := filepath.Join(l.PluginsDir, "managed", name)
|
|
if _, err := os.Stat(managedDir); err != nil {
|
|
return fmt.Errorf("plugin %q not found", name)
|
|
}
|
|
}
|
|
l.setPluginEnabled(name, enabled)
|
|
return nil
|
|
}
|
|
|
|
func (l *Loader) findUserPluginDir(name string) string {
|
|
// Try direct: user/{name}/
|
|
dir := filepath.Join(l.PluginsDir, "user", name)
|
|
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err == nil {
|
|
return dir
|
|
}
|
|
// Try workspace: user/{workspace}/{plugin}/
|
|
parts := strings.SplitN(name, "/", 2)
|
|
if len(parts) == 2 {
|
|
dir = filepath.Join(l.PluginsDir, "user", parts[0], parts[1])
|
|
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err == nil {
|
|
return dir
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (l *Loader) setPluginEnabled(name string, enabled bool) {
|
|
settings := l.loadSettings()
|
|
if settings.EnabledPlugins == nil {
|
|
settings.EnabledPlugins = make(map[string]bool)
|
|
}
|
|
settings.EnabledPlugins[name] = enabled
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
func (l *Loader) saveSettings(s *Settings) {
|
|
settingsPath := l.settingsPath()
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
slog.Debug("plugin: failed to marshal settings", "error", err)
|
|
return
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(settingsPath), 0o700)
|
|
_ = os.WriteFile(settingsPath, data, 0o600)
|
|
}
|
|
|
|
// GetPluginConfig returns the value of a config key for a plugin.
|
|
// It checks pluginConfigs in settings.json first, then falls back to
|
|
// the userConfig default in the plugin's manifest.
|
|
func (l *Loader) GetPluginConfig(pluginName, key string) (string, bool) {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs != nil {
|
|
if pluginCfg, ok := settings.PluginConfigs[pluginName]; ok {
|
|
if val, ok := pluginCfg[key]; ok {
|
|
if s, ok := val.(string); ok {
|
|
return s, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// SetPluginConfig persists a config key-value pair for a plugin.
|
|
func (l *Loader) SetPluginConfig(pluginName, key, value string) {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs == nil {
|
|
settings.PluginConfigs = make(map[string]map[string]any)
|
|
}
|
|
if settings.PluginConfigs[pluginName] == nil {
|
|
settings.PluginConfigs[pluginName] = make(map[string]any)
|
|
}
|
|
settings.PluginConfigs[pluginName][key] = value
|
|
l.saveSettings(settings)
|
|
}
|
|
|
|
// UnsetPluginConfig removes a config key for a plugin.
|
|
func (l *Loader) UnsetPluginConfig(pluginName, key string) bool {
|
|
settings := l.loadSettings()
|
|
if settings.PluginConfigs == nil {
|
|
return false
|
|
}
|
|
pluginCfg, ok := settings.PluginConfigs[pluginName]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if _, exists := pluginCfg[key]; !exists {
|
|
return false
|
|
}
|
|
delete(pluginCfg, key)
|
|
if len(pluginCfg) == 0 {
|
|
delete(settings.PluginConfigs, pluginName)
|
|
}
|
|
l.saveSettings(settings)
|
|
return true
|
|
}
|
|
|
|
// ListPluginConfig returns all config key-value pairs for a plugin.
|
|
func (l *Loader) ListPluginConfig(pluginName string) map[string]string {
|
|
settings := l.loadSettings()
|
|
result := make(map[string]string)
|
|
if settings.PluginConfigs == nil {
|
|
return result
|
|
}
|
|
pluginCfg, ok := settings.PluginConfigs[pluginName]
|
|
if !ok {
|
|
return result
|
|
}
|
|
for k, v := range pluginCfg {
|
|
if s, ok := v.(string); ok {
|
|
result[k] = s
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// InjectPluginConfigEnv reads pluginConfigs from settings.json and sets
|
|
// environment variables for each configured key. This allows
|
|
// expandPluginVars (which calls os.Expand) to resolve ${KEY} references
|
|
// in plugin.json headers, endpoints, etc.
|
|
//
|
|
// Environment variables already set by the user take precedence — only
|
|
// keys not already present in the environment are injected.
|
|
// dangerousEnvVars contains environment variable names that must never be
|
|
// set from plugin config because they can alter process behavior in
|
|
// security-critical ways (library injection, executable search path, etc.).
|
|
var dangerousEnvVars = map[string]bool{
|
|
"PATH": true, "HOME": true, "USER": true, "SHELL": true,
|
|
"LD_PRELOAD": true, "LD_LIBRARY_PATH": true,
|
|
"DYLD_INSERT_LIBRARIES": true, "DYLD_LIBRARY_PATH": true, "DYLD_FRAMEWORK_PATH": true,
|
|
"NODE_OPTIONS": true, "PYTHONPATH": true, "RUBYLIB": true,
|
|
"GOPATH": true, "GOROOT": true,
|
|
"HTTP_PROXY": true, "HTTPS_PROXY": true, "ALL_PROXY": true, "NO_PROXY": true,
|
|
"http_proxy": true, "https_proxy": true, "all_proxy": true, "no_proxy": true,
|
|
}
|
|
|
|
func (l *Loader) InjectPluginConfigEnv() {
|
|
settings := l.loadSettings()
|
|
if len(settings.PluginConfigs) == 0 {
|
|
return
|
|
}
|
|
for _, pluginCfg := range settings.PluginConfigs {
|
|
for key, val := range pluginCfg {
|
|
strVal, ok := val.(string)
|
|
if !ok || strVal == "" {
|
|
continue
|
|
}
|
|
// Block dangerous environment variable names.
|
|
if dangerousEnvVars[key] {
|
|
slog.Warn("plugin: blocked dangerous env var from config",
|
|
"key", key)
|
|
continue
|
|
}
|
|
// Do not override existing environment variables.
|
|
if _, exists := os.LookupEnv(key); exists {
|
|
continue
|
|
}
|
|
_ = os.Setenv(key, strVal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// LoadDev loads dev plugins registered via `dws plugin dev`.
|
|
// Dev plugins are loaded from their source directories without copying.
|
|
func (l *Loader) LoadDev() []*Plugin {
|
|
settings := l.loadSettings()
|
|
if len(settings.DevPlugins) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var plugins []*Plugin
|
|
for name, dir := range settings.DevPlugins {
|
|
if _, err := os.Stat(filepath.Join(dir, "plugin.json")); err != nil {
|
|
slog.Debug("plugin: dev plugin directory missing, skipping",
|
|
"name", name, "dir", dir)
|
|
continue
|
|
}
|
|
p := l.loadPlugin(dir, false)
|
|
if p != nil {
|
|
plugins = append(plugins, p)
|
|
slog.Debug("plugin: loaded dev plugin", "name", name, "dir", dir)
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// RegisterDevPlugin registers a source directory as a dev plugin.
|
|
func (l *Loader) RegisterDevPlugin(name, absDir string) error {
|
|
settings := l.loadSettings()
|
|
if settings.DevPlugins == nil {
|
|
settings.DevPlugins = make(map[string]string)
|
|
}
|
|
settings.DevPlugins[name] = absDir
|
|
l.saveSettings(settings)
|
|
return nil
|
|
}
|
|
|
|
// UnregisterDevPlugin removes a dev plugin registration.
|
|
func (l *Loader) UnregisterDevPlugin(name string) error {
|
|
settings := l.loadSettings()
|
|
if settings.DevPlugins == nil || settings.DevPlugins[name] == "" {
|
|
return fmt.Errorf("dev plugin %q is not registered", name)
|
|
}
|
|
delete(settings.DevPlugins, name)
|
|
l.saveSettings(settings)
|
|
return nil
|
|
}
|
|
|
|
// SyncSkills copies plugin SKILL.md files into all detected agent
|
|
// skill directories (e.g. ~/.claude/skills/dws/, ~/.cursor/skills/dws/).
|
|
// This makes plugin skills available to AI agents without CLI releases.
|
|
func SyncSkills(plugins []*Plugin) {
|
|
if len(plugins) == 0 {
|
|
return
|
|
}
|
|
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
slog.Debug("plugin: cannot get home dir for skill sync", "error", err)
|
|
return
|
|
}
|
|
|
|
// Known agent skill directories (subset of upgrade/paths.go knownSkillDirs).
|
|
agentDirs := []string{
|
|
".agents/skills",
|
|
".claude/skills",
|
|
".cursor/skills",
|
|
".qoder/skills",
|
|
".codex/skills",
|
|
}
|
|
|
|
for _, p := range plugins {
|
|
skillsDir := p.SkillsDir()
|
|
if _, err := os.Stat(skillsDir); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Walk the plugin's skills directory and copy files to each agent dir.
|
|
entries, err := os.ReadDir(skillsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, agentDir := range agentDirs {
|
|
agentBase := filepath.Join(homeDir, agentDir)
|
|
// Only sync to agents that are actually installed (parent dir exists).
|
|
parentGate := filepath.Dir(agentBase)
|
|
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
src := filepath.Join(skillsDir, entry.Name())
|
|
// Place plugin skills under dws/plugins/{plugin-name}/
|
|
dest := filepath.Join(agentBase, "dws", "plugins", p.Manifest.Name, entry.Name())
|
|
if entry.IsDir() {
|
|
_ = copyDir(src, dest)
|
|
} else {
|
|
_ = os.MkdirAll(filepath.Dir(dest), 0o755)
|
|
data, readErr := os.ReadFile(src)
|
|
if readErr == nil {
|
|
_ = os.WriteFile(dest, data, 0o644)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
slog.Debug("plugin: skill sync completed", "plugins", len(plugins))
|
|
}
|
|
|
|
// BuildPlugin runs the build command declared in plugin.json.
|
|
// It compiles the plugin's stdio server into a native binary so that
|
|
// users don't need language runtimes. Returns nil if no build is configured.
|
|
func BuildPlugin(pluginDir string) error {
|
|
manifest, err := ParseManifest(filepath.Join(pluginDir, "plugin.json"))
|
|
if err != nil {
|
|
return fmt.Errorf("parse manifest: %w", err)
|
|
}
|
|
if manifest.Build == nil {
|
|
return nil // no build configured
|
|
}
|
|
return runBuild(pluginDir, manifest.Build)
|
|
}
|
|
|
|
// runBuild executes the build command and verifies the output exists.
|
|
func runBuild(pluginDir string, build *BuildConfig) error {
|
|
if build.Command == "" {
|
|
return fmt.Errorf("build.command is empty")
|
|
}
|
|
|
|
// Validate build.output is a relative path within the plugin directory.
|
|
if build.Output != "" {
|
|
if filepath.IsAbs(build.Output) {
|
|
return fmt.Errorf("build.output must be a relative path, got %q", build.Output)
|
|
}
|
|
cleanOut := filepath.Clean(build.Output)
|
|
if strings.HasPrefix(cleanOut, "..") {
|
|
return fmt.Errorf("build.output must not escape plugin directory: %q", build.Output)
|
|
}
|
|
}
|
|
|
|
slog.Info("plugin: building", "dir", pluginDir, "command", build.Command)
|
|
|
|
var cmd *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
cmd = exec.Command("cmd", "/C", build.Command)
|
|
} else {
|
|
cmd = exec.Command("sh", "-c", build.Command)
|
|
}
|
|
cmd.Dir = pluginDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
// Pass through environment + plugin root
|
|
cmd.Env = append(os.Environ(), "DWS_PLUGIN_ROOT="+pluginDir)
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("build failed: %w", err)
|
|
}
|
|
|
|
// Verify output binary exists
|
|
if build.Output != "" {
|
|
outPath := filepath.Join(pluginDir, build.Output)
|
|
info, err := os.Stat(outPath)
|
|
if err != nil {
|
|
return fmt.Errorf("build output not found at %s: %w", build.Output, err)
|
|
}
|
|
// Ensure the output is executable
|
|
if info.Mode()&0o111 == 0 {
|
|
_ = os.Chmod(outPath, info.Mode()|0o755)
|
|
}
|
|
}
|
|
|
|
slog.Info("plugin: build succeeded", "output", build.Output)
|
|
return nil
|
|
}
|
|
|
|
// copyDir recursively copies src to dst, skipping files whose content
|
|
// is identical to the destination. This avoids overwriting locked
|
|
// executables (e.g. a running stdio plugin on Windows).
|
|
// Symlinks are skipped for security (prevents path traversal attacks).
|
|
func copyDir(src, dst string) error {
|
|
cleanDst := filepath.Clean(dst) + string(os.PathSeparator)
|
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip symlinks to prevent path traversal.
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
// Guard against path traversal via crafted relative paths.
|
|
if target != cleanDst[:len(cleanDst)-1] && !strings.HasPrefix(target, cleanDst) {
|
|
return fmt.Errorf("path traversal detected: %s", rel)
|
|
}
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, info.Mode())
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip if destination already has identical content (cheap size check first).
|
|
if targetInfo, statErr := os.Stat(target); statErr == nil && targetInfo.Size() == int64(len(data)) {
|
|
if existing, readErr := os.ReadFile(target); readErr == nil && bytes.Equal(existing, data) {
|
|
return nil
|
|
}
|
|
}
|
|
return os.WriteFile(target, data, info.Mode())
|
|
})
|
|
}
|
|
|
|
// removeStaleFiles deletes files under dst that do not exist in src.
|
|
// Best-effort: errors are logged but do not fail the install.
|
|
func removeStaleFiles(src, dst string) {
|
|
srcSet := make(map[string]struct{})
|
|
_ = filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(src, path)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
srcSet[rel] = struct{}{}
|
|
return nil
|
|
})
|
|
|
|
_ = filepath.Walk(dst, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(dst, path)
|
|
if relErr != nil {
|
|
return nil
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if _, exists := srcSet[rel]; !exists {
|
|
if info.IsDir() {
|
|
_ = os.RemoveAll(path)
|
|
return filepath.SkipDir
|
|
}
|
|
if removeErr := os.Remove(path); removeErr != nil {
|
|
slog.Debug("plugin: failed to remove stale file", "path", path, "error", removeErr)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|