// 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 } var ( pluginUserHomeDir = os.UserHomeDir pluginReadDir = os.ReadDir pluginMkdirTemp = os.MkdirTemp pluginRemoveAll = os.RemoveAll pluginMkdirAll = os.MkdirAll pluginReadFile = os.ReadFile pluginWriteFile = os.WriteFile pluginRemove = os.Remove pluginWalk = filepath.Walk pluginRel = filepath.Rel pluginCopyDir = copyDir pluginRunBuild = runBuild ) // NewLoader creates a Loader with default paths. func NewLoader(cliVersion string) *Loader { configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR")) if configDir == "" { home, _ := pluginUserHomeDir() configDir = filepath.Join(home, ".dws") } return &Loader{ PluginsDir: filepath.Join(configDir, "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 } // 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 := pluginReadDir(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) if p != nil && isPluginEnabled(settings, p.Manifest.Name) { plugins = append(plugins, p) } continue } // Otherwise treat as workspace directory: user/{workspace}/{name}/ subEntries, err := pluginReadDir(entryPath) if err != nil { continue } for _, sub := range subEntries { if !sub.IsDir() { continue } subPath := filepath.Join(entryPath, sub.Name()) p := l.loadPlugin(subPath) if p != nil { qualifiedName := entry.Name() + "/" + p.Manifest.Name if isPluginEnabled(settings, qualifiedName) { plugins = append(plugins, p) } } } } return plugins } // LoadAll loads user + dev plugins. func (l *Loader) LoadAll() []*Plugin { user := l.LoadUser() dev := l.LoadDev() return append(user, dev...) } // loadPlugin reads and validates a single plugin directory. func (l *Loader) loadPlugin(dir string) *Plugin { manifestPath := filepath.Join(dir, "plugin.json") manifest, err := ParseManifest(manifestPath) if err != nil { slog.Debug("plugin: failed to parse manifest", "path", manifestPath, "error", err) return nil } if err := manifest.Validate(l.CLIVersion); err != nil { slog.Debug("plugin: validation failed", "plugin", manifest.Name, "error", err) return nil } return &Plugin{ Manifest: *manifest, Root: dir, } } // 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"` // "user" or "dev" 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() // 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 := pluginReadDir(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 := pluginCopyDir(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 := pluginRunBuild(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, }, 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 := pluginMkdirTemp("", "dws-plugin-git-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer pluginRemoveAll(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. _ = pluginRemoveAll(filepath.Join(cloneDir, ".git")) if err := pluginCopyDir(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 := pluginRunBuild(destDir, manifest.Build); err != nil { // Clean up on build failure. _ = pluginRemoveAll(destDir) return nil, fmt.Errorf("plugin build failed: %w", err) } } qualifiedName := workspace + "/" + manifest.Name l.setPluginEnabled(qualifiedName, true) return &Plugin{ Manifest: *manifest, Root: destDir, }, 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. func (l *Loader) RemovePlugin(name string, keepData bool) error { pluginDir := l.findUserPluginDir(name) if pluginDir == "" { return fmt.Errorf("plugin %q not found", name) } if err := pluginRemoveAll(pluginDir); err != nil { return fmt.Errorf("failed to remove plugin: %w", err) } if !keepData { dataDir := filepath.Join(l.PluginsDir, config.PluginDataDir, name) _ = pluginRemoveAll(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 { if l.findUserPluginDir(name) == "" { return fmt.Errorf("plugin %q not found", name) } l.setPluginEnabled(name, enabled) return nil } func (l *Loader) findUserPluginDir(name string) string { // name may be direct ("plugin") or qualified ("workspace/plugin"). // filepath.Join handles both forms. dir := filepath.Join(l.PluginsDir, "user", name) 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) 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 := pluginUserHomeDir() 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 := pluginReadDir(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() { _ = pluginCopyDir(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) cmd := buildCommandFor(runtime.GOOS, 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 } func buildCommandFor(goos, command string) *exec.Cmd { if goos == "windows" { return exec.Command("cmd", "/C", command) } return exec.Command("sh", "-c", command) } // 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 := pluginMkdirAll(dst, 0o755); err != nil { return err } return pluginWalk(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 := pluginRel(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 pluginMkdirAll(target, info.Mode()) } data, err := pluginReadFile(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 pluginWriteFile(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{}) _ = pluginWalk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } rel, relErr := pluginRel(src, path) if relErr != nil { return nil } srcSet[rel] = struct{}{} return nil }) _ = pluginWalk(dst, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } rel, relErr := pluginRel(dst, path) if relErr != nil { return nil } if rel == "." { return nil } if _, exists := srcSet[rel]; !exists { if info.IsDir() { _ = pluginRemoveAll(path) return filepath.SkipDir } if removeErr := pluginRemove(path); removeErr != nil { slog.Debug("plugin: failed to remove stale file", "path", path, "error", removeErr) } } return nil }) }