diff --git a/README.md b/README.md index 9673faae8..e4ac18402 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter). **Plugin System** -Extend functionality with the [plugin registry](https://plugins.danklinux.com). +Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps +`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and +their exact Git commits, so the same plugins can be reproduced on another machine. ## Supported Compositors @@ -120,6 +122,8 @@ dms ipc call audio setvolume 50 dms ipc call wallpaper set /path/to/image.jpg dms brightness list # List available displays dms plugins search # Browse plugin registry +dms plugins lock # Refresh the portable plugin lockfile +dms plugins restore ~/plugins.lock.json ``` [Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc) diff --git a/core/README.md b/core/README.md index 099195d3c..ea07a91cf 100644 --- a/core/README.md +++ b/core/README.md @@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication. - `dms run [-d]` - Start shell (optionally as daemon) - `dms restart` / `dms kill` - Manage running processes - `dms ipc ` - Send IPC commands (toggle launcher, notifications, etc.) -- `dms plugins [install|browse|search]` - Plugin management +- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles - `dms brightness [list|set]` - Control display/monitor brightness - `dms color pick` - Native color picker (see below) - `dms update` - Update DMS and dependencies (disabled in distro packages) diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 7c9d0d8b3..139d23329 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -51,6 +51,8 @@ func init() { }) pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins") pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them") + pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path") + pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile") } var debugSrvCmd = &cobra.Command{ @@ -178,6 +180,36 @@ var pluginsUpdateCmd = &cobra.Command{ }, } +var pluginsLockCmd = &cobra.Command{ + Use: "lock", + Short: "Record installed plugins and their exact revisions", + Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + output, _ := cmd.Flags().GetString("output") + if err := lockPluginsCLI(output); err != nil { + log.Fatalf("Error writing plugin lockfile: %v", err) + } + }, +} + +var pluginsRestoreCmd = &cobra.Command{ + Use: "restore [lockfile]", + Short: "Restore plugins from exact revisions in a lockfile", + Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + path := "" + if len(args) == 1 { + path = args[0] + } + prune, _ := cmd.Flags().GetBool("prune") + if err := restorePluginsCLI(path, prune); err != nil { + log.Fatalf("Error restoring plugins: %v", err) + } + }, +} + func runVersion(cmd *cobra.Command, args []string) { fmt.Printf("%s\n", formatVersion(Version)) } @@ -433,6 +465,40 @@ func installPluginCLI(idOrName string) error { return nil } +func lockPluginsCLI(outputPath string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + warnings, err := manager.WriteCurrentLockfile(outputPath) + if err != nil { + return err + } + for _, warning := range warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath()) + if outputPath != "" && outputPath != manager.GetLockfilePath() { + fmt.Printf("Plugin lockfile exported: %s\n", outputPath) + } + return nil +} + +func restorePluginsCLI(path string, prune bool) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + if err := manager.RestoreFromLockfile(path, prune); err != nil { + return err + } + if path == "" { + path = manager.GetLockfilePath() + } + fmt.Printf("Plugins restored from lockfile: %s\n", path) + return nil +} + func getAvailablePluginIDs() []string { registry, err := plugins.NewRegistry() if err != nil { diff --git a/core/cmd/dms/main.go b/core/cmd/dms/main.go index 155ce9ea8..ec865a4ab 100644 --- a/core/cmd/dms/main.go +++ b/core/cmd/dms/main.go @@ -15,7 +15,7 @@ func init() { authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) updateCmd.AddCommand(updateCheckCmd) - pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd) registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd) rootCmd.AddCommand(getCommonCommands()...) diff --git a/core/cmd/dms/main_distro.go b/core/cmd/dms/main_distro.go index df5592df8..783e446b3 100644 --- a/core/cmd/dms/main_distro.go +++ b/core/cmd/dms/main_distro.go @@ -14,7 +14,7 @@ var Version = "dev" func init() { authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) - pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd) rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(authCmd) diff --git a/core/internal/plugins/lockfile.go b/core/internal/plugins/lockfile.go new file mode 100644 index 000000000..1788ef6c5 --- /dev/null +++ b/core/internal/plugins/lockfile.go @@ -0,0 +1,197 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/spf13/afero" +) + +const pluginLockfileVersion = 1 + +var gitCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +var scpLikeRepoPattern = regexp.MustCompile(`^[^@:/\s]+@[^@:/\s]+:\S+$`) + +type PluginLockfile struct { + LockfileVersion int `json:"lockfileVersion"` + Plugins map[string]LockedPlugin `json:"plugins"` +} + +type LockedPlugin struct { + Repo string `json:"repo"` + Path string `json:"path,omitempty"` + Commit string `json:"commit"` +} + +type LockStore struct { + fs afero.Fs + path string +} + +func NewLockStore(fs afero.Fs, path string) *LockStore { + return &LockStore{fs: fs, path: path} +} + +func NewPluginLockfile() PluginLockfile { + return PluginLockfile{ + LockfileVersion: pluginLockfileVersion, + Plugins: make(map[string]LockedPlugin), + } +} + +func (s *LockStore) Path() string { + return s.path +} + +func (s *LockStore) Exists() (bool, error) { + return afero.Exists(s.fs, s.path) +} + +func (s *LockStore) Load() (PluginLockfile, error) { + data, err := afero.ReadFile(s.fs, s.path) + if err != nil { + if os.IsNotExist(err) { + return NewPluginLockfile(), nil + } + return PluginLockfile{}, fmt.Errorf("failed to read plugin lockfile: %w", err) + } + + return ParsePluginLockfile(data) +} + +func ParsePluginLockfile(data []byte) (PluginLockfile, error) { + var lock PluginLockfile + if err := json.Unmarshal(data, &lock); err != nil { + return PluginLockfile{}, fmt.Errorf("failed to parse plugin lockfile: %w", err) + } + if lock.Plugins == nil { + lock.Plugins = make(map[string]LockedPlugin) + } + if err := lock.Validate(); err != nil { + return PluginLockfile{}, err + } + return lock, nil +} + +func (s *LockStore) Write(lock PluginLockfile) error { + if err := lock.Validate(); err != nil { + return err + } + + data, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return fmt.Errorf("failed to encode plugin lockfile: %w", err) + } + data = append(data, '\n') + + if err := s.fs.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("failed to create plugin lockfile directory: %w", err) + } + + tmpPath := s.path + ".tmp" + if err := afero.WriteFile(s.fs, tmpPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write temporary plugin lockfile: %w", err) + } + defer s.fs.Remove(tmpPath) //nolint:errcheck + + if err := s.fs.Rename(tmpPath, s.path); err != nil { + return fmt.Errorf("failed to replace plugin lockfile: %w", err) + } + return nil +} + +func (lock PluginLockfile) Validate() error { + if lock.LockfileVersion != pluginLockfileVersion { + return fmt.Errorf("unsupported plugin lockfile version %d (expected %d)", lock.LockfileVersion, pluginLockfileVersion) + } + + repoCommits := make(map[string]string) + for id, plugin := range lock.Plugins { + if !isSafePluginPathComponent(id) { + return fmt.Errorf("invalid locked plugin id: %q", id) + } + if strings.TrimSpace(plugin.Repo) == "" { + return fmt.Errorf("locked plugin %q has no repository", id) + } + if err := validatePluginRepo(plugin.Repo); err != nil { + return fmt.Errorf("locked plugin %q: %w", id, err) + } + if !gitCommitPattern.MatchString(plugin.Commit) { + return fmt.Errorf("locked plugin %q has invalid commit %q", id, plugin.Commit) + } + if err := validatePluginRepoPath(plugin.Path); err != nil { + return fmt.Errorf("locked plugin %q: %w", id, err) + } + if commit, ok := repoCommits[plugin.Repo]; ok && !strings.EqualFold(commit, plugin.Commit) { + return fmt.Errorf("plugins from repository %q must use the same commit", plugin.Repo) + } + repoCommits[plugin.Repo] = plugin.Commit + } + return nil +} + +func validatePluginRepo(repo string) error { + parsed, err := url.Parse(repo) + if err != nil { + if scpLikeRepoPattern.MatchString(repo) { + return nil + } + return fmt.Errorf("invalid repository URL %q", repo) + } + if parsed.User == nil { + return nil + } + if _, hasPassword := parsed.User.Password(); hasPassword { + return fmt.Errorf("repository URL must not contain credentials") + } + switch parsed.Scheme { + case "http", "https": + return fmt.Errorf("repository URL must not contain credentials") + } + return nil +} + +func validatePluginRepoPath(path string) error { + if path == "" { + return nil + } + clean := filepath.Clean(path) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("invalid repository path %q", path) + } + return nil +} + +func (lock PluginLockfile) IDs() []string { + ids := make([]string, 0, len(lock.Plugins)) + for id := range lock.Plugins { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func (lock *PluginLockfile) SetRepositoryCommit(repo, commit string) { + for id, plugin := range lock.Plugins { + if plugin.Repo != repo { + continue + } + plugin.Commit = commit + lock.Plugins[id] = plugin + } +} + +func (lock PluginLockfile) Clone() PluginLockfile { + cloned := NewPluginLockfile() + for id, plugin := range lock.Plugins { + cloned.Plugins[id] = plugin + } + return cloned +} diff --git a/core/internal/plugins/lockfile_test.go b/core/internal/plugins/lockfile_test.go new file mode 100644 index 000000000..713618eaf --- /dev/null +++ b/core/internal/plugins/lockfile_test.go @@ -0,0 +1,111 @@ +package plugins + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testCommit = "0123456789abcdef0123456789abcdef01234567" + +func TestPluginLockfileRoundTrip(t *testing.T) { + fs := afero.NewMemMapFs() + store := NewLockStore(fs, "/config/plugins.lock.json") + lock := NewPluginLockfile() + lock.Plugins["weather"] = LockedPlugin{ + Repo: "https://github.com/example/plugins.git", + Path: "plugins/weather", + Commit: testCommit, + } + + require.NoError(t, store.Write(lock)) + loaded, err := store.Load() + require.NoError(t, err) + assert.Equal(t, lock, loaded) + + data, err := afero.ReadFile(fs, store.Path()) + require.NoError(t, err) + assert.Equal(t, "{\n \"lockfileVersion\": 1,\n \"plugins\": {\n \"weather\": {\n \"repo\": \"https://github.com/example/plugins.git\",\n \"path\": \"plugins/weather\",\n \"commit\": \"0123456789abcdef0123456789abcdef01234567\"\n }\n }\n}\n", string(data)) +} + +func TestPluginLockfileValidation(t *testing.T) { + tests := []struct { + name string + lock PluginLockfile + want string + }{ + { + name: "rejects unsupported version", + lock: PluginLockfile{LockfileVersion: 2, Plugins: map[string]LockedPlugin{}}, + want: "unsupported plugin lockfile version", + }, + { + name: "rejects unsafe id", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"../bad": {Repo: "repo", Commit: testCommit}}}, + want: "invalid locked plugin id", + }, + { + name: "rejects unsafe repository path", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Path: "../bad", Commit: testCommit}}}, + want: "invalid repository path", + }, + { + name: "rejects abbreviated commit", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "repo", Commit: "abc123"}}}, + want: "invalid commit", + }, + { + name: "rejects repository credentials", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "https://token@example.com/plugin.git", Commit: testCommit}}}, + want: "must not contain credentials", + }, + { + name: "rejects ssh password", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{"bad": {Repo: "ssh://git:secret@example.com/plugin.git", Commit: testCommit}}}, + want: "must not contain credentials", + }, + { + name: "rejects conflicting monorepo commits", + lock: PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{ + "one": {Repo: "repo", Commit: testCommit}, + "two": {Repo: "repo", Commit: "1123456789abcdef0123456789abcdef01234567"}, + }}, + want: "must use the same commit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.lock.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +func TestPluginLockfileAcceptsSSHRemotes(t *testing.T) { + repos := []string{ + "https://github.com/user/plugin.git", + "ssh://git@github.com/user/plugin.git", + "git@github.com:user/plugin.git", + } + + for _, repo := range repos { + t.Run(repo, func(t *testing.T) { + lock := PluginLockfile{LockfileVersion: 1, Plugins: map[string]LockedPlugin{ + "plugin": {Repo: repo, Commit: testCommit}, + }} + assert.NoError(t, lock.Validate()) + }) + } +} + +func TestMissingPluginLockfileIsEmpty(t *testing.T) { + store := NewLockStore(afero.NewMemMapFs(), "/config/plugins.lock.json") + lock, err := store.Load() + require.NoError(t, err) + assert.Equal(t, pluginLockfileVersion, lock.LockfileVersion) + assert.Empty(t, lock.Plugins) +} diff --git a/core/internal/plugins/manager.go b/core/internal/plugins/manager.go index 37d229d68..40237a5a6 100644 --- a/core/internal/plugins/manager.go +++ b/core/internal/plugins/manager.go @@ -16,6 +16,7 @@ import ( type Manager struct { fs afero.Fs pluginsDir string + lockPath string gitClient GitClient } @@ -28,6 +29,7 @@ func NewManagerWithFs(fs afero.Fs) (*Manager, error) { return &Manager{ fs: fs, pluginsDir: pluginsDir, + lockPath: getPluginLockPath(), gitClient: &realGitClient{}, }, nil } @@ -41,6 +43,15 @@ func getPluginsDir() string { return filepath.Join(configDir, "DankMaterialShell", "plugins") } +func getPluginLockPath() string { + configDir, err := os.UserConfigDir() + if err != nil { + log.Error("failed to get user config dir", "err", err) + return "" + } + return filepath.Join(configDir, "DankMaterialShell", "plugins.lock.json") +} + func (m *Manager) IsInstalled(plugin Plugin) (bool, error) { path, err := m.findInstalledPath(plugin.ID) if err != nil { @@ -118,7 +129,12 @@ func (m *Manager) findInDir(dir, pluginID string) (string, error) { } func (m *Manager) Install(plugin Plugin) error { + if !isSafePluginPathComponent(plugin.ID) { + return fmt.Errorf("invalid plugin id: %q", plugin.ID) + } + pluginPath := filepath.Join(m.pluginsDir, plugin.ID) + repoPath := pluginPath exists, err := afero.DirExists(m.fs, pluginPath) if err != nil { @@ -128,6 +144,18 @@ func (m *Manager) Install(plugin Plugin) error { if exists { return fmt.Errorf("plugin already installed: %s", plugin.Name) } + if strings.TrimSpace(plugin.Repo) == "" { + return fmt.Errorf("plugin repository is required") + } + if err := validatePluginRepo(plugin.Repo); err != nil { + return err + } + if err := validatePluginRepoPath(plugin.Path); err != nil { + return err + } + if _, err := m.ensureLockfile(); err != nil { + return err + } if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil { return fmt.Errorf("failed to create plugins directory: %w", err) @@ -140,7 +168,7 @@ func (m *Manager) Install(plugin Plugin) error { if plugin.Path != "" { repoName := m.getRepoName(plugin.Repo) - repoPath := filepath.Join(reposDir, repoName) + repoPath = filepath.Join(reposDir, repoName) repoExists, err := afero.DirExists(m.fs, repoPath) if err != nil { @@ -178,12 +206,13 @@ func (m *Manager) Install(plugin Plugin) error { if err := m.createSymlink(sourcePath, pluginPath); err != nil { return fmt.Errorf("failed to create symlink: %w", err) } - metaPath := pluginPath + ".meta" metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName) if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil { - return fmt.Errorf("failed to write metadata: %w", err) + m.fs.Remove(pluginPath) //nolint:errcheck + return fmt.Errorf("failed to write plugin repository metadata: %w", err) } + } else { if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil { m.fs.RemoveAll(pluginPath) //nolint:errcheck @@ -191,6 +220,16 @@ func (m *Manager) Install(plugin Plugin) error { } } + if err := m.recordInstalledPlugin(plugin, repoPath); err != nil { + if plugin.Path != "" { + m.fs.Remove(pluginPath) //nolint:errcheck + m.fs.Remove(pluginPath + ".meta") //nolint:errcheck + } else { + m.fs.RemoveAll(pluginPath) //nolint:errcheck + } + return fmt.Errorf("failed to update plugin lockfile: %w", err) + } + return nil } @@ -207,6 +246,15 @@ func (m *Manager) createSymlink(source, dest string) error { } func (m *Manager) Update(plugin Plugin) error { + lock, err := m.ensureLockfile() + if err != nil { + return err + } + if locked, ok := lock.Plugins[plugin.ID]; ok { + plugin.Repo = locked.Repo + plugin.Path = locked.Path + } + pluginPath, err := m.findInstalledPath(plugin.ID) if err != nil { return fmt.Errorf("failed to find plugin: %w", err) @@ -219,46 +267,42 @@ func (m *Manager) Update(plugin Plugin) error { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { return fmt.Errorf("cannot update system plugin: %s", plugin.Name) } - - metaPath := pluginPath + ".meta" - metaExists, err := afero.Exists(m.fs, metaPath) - if err != nil { - return fmt.Errorf("failed to check metadata: %w", err) - } - - if metaExists { - reposDir := filepath.Join(m.pluginsDir, ".repos") - repoName := m.getRepoName(plugin.Repo) - repoPath := filepath.Join(reposDir, repoName) - - // Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone - if err := m.gitClient.Pull(repoPath); err != nil { - // Repository is likely corrupted or has issues, delete and re-clone - if err := m.fs.RemoveAll(repoPath); err != nil { - return fmt.Errorf("failed to remove corrupted repository: %w", err) - } - - if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil { - return fmt.Errorf("failed to re-clone repository: %w", err) - } - } - } else { - // Try to pull, if it fails, delete and re-clone - if err := m.gitClient.Pull(pluginPath); err != nil { - if err := m.fs.RemoveAll(pluginPath); err != nil { - return fmt.Errorf("failed to remove corrupted plugin: %w", err) - } - - if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil { - return fmt.Errorf("failed to re-clone plugin: %w", err) - } + if plugin.Repo == "" { + plugin.Repo, err = m.gitClient.OriginURL(pluginPath) + if err != nil { + return fmt.Errorf("failed to read plugin origin: %w", err) } } + repoPath := m.repositoryPath(plugin.ID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path}) + if err := m.gitClient.Pull(repoPath); err != nil { + if err := m.fs.RemoveAll(repoPath); err != nil { + return fmt.Errorf("failed to remove corrupted plugin repository: %w", err) + } + if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil { + return fmt.Errorf("failed to re-clone plugin repository: %w", err) + } + } + + if err := m.recordInstalledPlugin(plugin, repoPath); err != nil { + if locked, ok := lock.Plugins[plugin.ID]; ok { + m.gitClient.CheckoutRevision(repoPath, locked.Commit) //nolint:errcheck + } + return err + } return nil } func (m *Manager) Uninstall(plugin Plugin) error { + lock, err := m.ensureLockfile() + if err != nil { + return err + } + if locked, ok := lock.Plugins[plugin.ID]; ok { + plugin.Repo = locked.Repo + plugin.Path = locked.Path + } + pluginPath, err := m.findInstalledPath(plugin.ID) if err != nil { return fmt.Errorf("failed to find plugin: %w", err) @@ -271,70 +315,64 @@ func (m *Manager) Uninstall(plugin Plugin) error { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name) } - - metaPath := pluginPath + ".meta" - metaExists, err := afero.Exists(m.fs, metaPath) - if err != nil { - return fmt.Errorf("failed to check metadata: %w", err) + updatedLock := lock.Clone() + delete(updatedLock.Plugins, plugin.ID) + if err := m.lockStore().Write(updatedLock); err != nil { + return err + } + rollbackLock := func(err error) error { + if rollbackErr := m.lockStore().Write(lock); rollbackErr != nil { + return fmt.Errorf("%w (also failed to restore plugin lockfile: %v)", err, rollbackErr) + } + return err } - if metaExists { + metaPath := pluginPath + ".meta" + if plugin.Path != "" { reposDir := filepath.Join(m.pluginsDir, ".repos") repoName := m.getRepoName(plugin.Repo) repoPath := filepath.Join(reposDir, repoName) - shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID) + shouldCleanup, err := m.shouldCleanupRepo(plugin.Repo, plugin.ID) if err != nil { - return fmt.Errorf("failed to check repo cleanup: %w", err) + return rollbackLock(fmt.Errorf("failed to check repo cleanup: %w", err)) } if err := m.fs.Remove(pluginPath); err != nil { - return fmt.Errorf("failed to remove symlink: %w", err) + return rollbackLock(fmt.Errorf("failed to remove symlink: %w", err)) } - if err := m.fs.Remove(metaPath); err != nil { - return fmt.Errorf("failed to remove metadata: %w", err) + if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists { + if err := m.fs.Remove(metaPath); err != nil { + return rollbackLock(fmt.Errorf("failed to remove metadata: %w", err)) + } } if shouldCleanup { if err := m.fs.RemoveAll(repoPath); err != nil { - return fmt.Errorf("failed to cleanup repository: %w", err) + return rollbackLock(fmt.Errorf("failed to cleanup repository: %w", err)) } } } else { if err := m.fs.RemoveAll(pluginPath); err != nil { - return fmt.Errorf("failed to remove plugin: %w", err) + return rollbackLock(fmt.Errorf("failed to remove plugin: %w", err)) } } return nil } -func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) { - installed, err := m.ListInstalled() +func (m *Manager) shouldCleanupRepo(repoURL, excludePlugin string) (bool, error) { + lock, err := m.ensureLockfile() if err != nil { return false, err } - - registry, err := NewRegistry() - if err != nil { - return false, err - } - - allPlugins, err := registry.List() - if err != nil { - return false, err - } - - for _, id := range installed { + for id, plugin := range lock.Plugins { if id == excludePlugin { continue } - - for _, p := range allPlugins { - if p.ID == id && p.Repo == repoURL && p.Path != "" { - return false, nil - } + if plugin.Repo == repoURL && plugin.Path != "" { + return false, nil } } @@ -456,6 +494,16 @@ func (m *Manager) UninstallByIDOrName(idOrName string) error { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { return fmt.Errorf("cannot uninstall system plugin: %s", idOrName) } + manifest := m.getPluginManifest(pluginPath) + if manifest != nil { + lock, err := m.ensureLockfile() + if err != nil { + return err + } + if locked, ok := lock.Plugins[manifest.ID]; ok { + return m.Uninstall(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path}) + } + } metaPath := pluginPath + ".meta" metaExists, _ := afero.Exists(m.fs, metaPath) @@ -488,6 +536,16 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error { if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { return fmt.Errorf("cannot update system plugin: %s", idOrName) } + manifest := m.getPluginManifest(pluginPath) + if manifest != nil { + lock, err := m.ensureLockfile() + if err != nil { + return err + } + if locked, ok := lock.Plugins[manifest.ID]; ok { + return m.Update(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: locked.Repo, Path: locked.Path}) + } + } metaPath := pluginPath + ".meta" metaExists, _ := afero.Exists(m.fs, metaPath) @@ -502,8 +560,14 @@ func (m *Manager) UpdateByIDOrName(idOrName string) error { if err := m.gitClient.Pull(pluginPath); err != nil { return fmt.Errorf("failed to update plugin: %w", err) } - - return nil + if manifest == nil { + return nil + } + repo, err := m.gitClient.OriginURL(pluginPath) + if err != nil { + return fmt.Errorf("failed to read plugin origin: %w", err) + } + return m.recordInstalledPlugin(Plugin{ID: manifest.ID, Name: manifest.Name, Repo: repo}, pluginPath) } func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) { @@ -572,6 +636,15 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) { } func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) { + lock, err := m.ensureLockfile() + if err != nil { + return false, "", err + } + if locked, ok := lock.Plugins[pluginID]; ok { + plugin.Repo = locked.Repo + plugin.Path = locked.Path + } + pluginPath, err := m.findInstalledPath(pluginID) if err != nil { return false, "", fmt.Errorf("failed to find plugin: %w", err) @@ -585,25 +658,11 @@ func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, d return false, "", nil } - metaPath := pluginPath + ".meta" - metaExists, err := afero.Exists(m.fs, metaPath) - if err != nil { - return false, "", fmt.Errorf("failed to check metadata: %w", err) - } - - var hasUp bool - var localHash, remoteHash string - if metaExists { - // Plugin is from a monorepo, check the repo directory - reposDir := filepath.Join(m.pluginsDir, ".repos") - repoName := m.getRepoName(plugin.Repo) - repoPath := filepath.Join(reposDir, repoName) - - hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath) - } else { - // Plugin is a standalone repo - hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath) + repoPath := pluginPath + if plugin.Path != "" { + repoPath = m.repositoryPath(pluginID, LockedPlugin{Repo: plugin.Repo, Path: plugin.Path}) } + hasUp, localHash, remoteHash, err := m.gitClient.HasUpdates(repoPath) if err != nil { return false, "", err diff --git a/core/internal/plugins/manager_lockfile.go b/core/internal/plugins/manager_lockfile.go new file mode 100644 index 000000000..66244dca1 --- /dev/null +++ b/core/internal/plugins/manager_lockfile.go @@ -0,0 +1,299 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/afero" +) + +func (m *Manager) lockStore() *LockStore { + lockPath := m.lockPath + if lockPath == "" { + lockPath = filepath.Join(filepath.Dir(m.pluginsDir), "plugins.lock.json") + } + return NewLockStore(m.fs, lockPath) +} + +func (m *Manager) GetLockfilePath() string { + return m.lockStore().Path() +} + +func (m *Manager) ensureLockfile() (PluginLockfile, error) { + store := m.lockStore() + exists, err := store.Exists() + if err != nil { + return PluginLockfile{}, fmt.Errorf("failed to check plugin lockfile: %w", err) + } + if exists { + return store.Load() + } + + lock, _, err := m.SnapshotLockfile() + if err != nil { + return PluginLockfile{}, err + } + if err := store.Write(lock); err != nil { + return PluginLockfile{}, err + } + return lock, nil +} + +func (m *Manager) SnapshotLockfile() (PluginLockfile, []string, error) { + lock := NewPluginLockfile() + warnings := []string{} + + exists, err := afero.DirExists(m.fs, m.pluginsDir) + if err != nil { + return PluginLockfile{}, nil, fmt.Errorf("failed to check plugins directory: %w", err) + } + if !exists { + return lock, warnings, nil + } + + entries, err := afero.ReadDir(m.fs, m.pluginsDir) + if err != nil { + return PluginLockfile{}, nil, fmt.Errorf("failed to read plugins directory: %w", err) + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + pluginPath := filepath.Join(m.pluginsDir, name) + manifest := m.getPluginManifest(pluginPath) + if manifest == nil || manifest.ID == "" { + continue + } + + locked, repoPath, err := m.lockedPluginFromInstall(pluginPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", manifest.ID, err)) + continue + } + commit, err := m.gitClient.CurrentRevision(repoPath) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: cannot read git revision: %v", manifest.ID, err)) + continue + } + locked.Commit = commit + lock.SetRepositoryCommit(locked.Repo, commit) + lock.Plugins[manifest.ID] = locked + } + + if err := lock.Validate(); err != nil { + return PluginLockfile{}, warnings, err + } + return lock, warnings, nil +} + +func (m *Manager) lockedPluginFromInstall(pluginPath string) (LockedPlugin, string, error) { + metaPath := pluginPath + ".meta" + if metaExists, _ := afero.Exists(m.fs, metaPath); metaExists { + data, err := afero.ReadFile(m.fs, metaPath) + if err != nil { + return LockedPlugin{}, "", fmt.Errorf("failed to read legacy metadata: %w", err) + } + metadata := parseLegacyPluginMetadata(string(data)) + repo := metadata["repo"] + path := metadata["path"] + repoDir := metadata["repodir"] + if repo == "" || repoDir == "" { + return LockedPlugin{}, "", fmt.Errorf("legacy metadata is incomplete") + } + if err := validatePluginRepo(repo); err != nil { + return LockedPlugin{}, "", err + } + if !isSafePluginPathComponent(repoDir) { + return LockedPlugin{}, "", fmt.Errorf("legacy repository directory is invalid") + } + if err := validatePluginRepoPath(path); err != nil { + return LockedPlugin{}, "", err + } + return LockedPlugin{Repo: repo, Path: path}, filepath.Join(m.pluginsDir, ".repos", repoDir), nil + } + + repo, err := m.gitClient.OriginURL(pluginPath) + if err != nil { + return LockedPlugin{}, "", fmt.Errorf("plugin is not a managed git checkout") + } + if err := validatePluginRepo(repo); err != nil { + return LockedPlugin{}, "", err + } + return LockedPlugin{Repo: repo}, pluginPath, nil +} + +func parseLegacyPluginMetadata(data string) map[string]string { + metadata := make(map[string]string) + for _, line := range strings.Split(data, "\n") { + key, value, ok := strings.Cut(line, "=") + if ok { + metadata[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + return metadata +} + +func (m *Manager) recordInstalledPlugin(plugin Plugin, repoPath string) error { + lock, err := m.ensureLockfile() + if err != nil { + return err + } + commit, err := m.gitClient.CurrentRevision(repoPath) + if err != nil { + return fmt.Errorf("failed to read installed plugin revision: %w", err) + } + + lock.SetRepositoryCommit(plugin.Repo, commit) + lock.Plugins[plugin.ID] = LockedPlugin{ + Repo: plugin.Repo, + Path: plugin.Path, + Commit: commit, + } + return m.lockStore().Write(lock) +} + +func (m *Manager) WriteCurrentLockfile(outputPath string) ([]string, error) { + lock, warnings, err := m.SnapshotLockfile() + if err != nil { + return warnings, err + } + if err := m.lockStore().Write(lock); err != nil { + return warnings, err + } + if outputPath != "" && outputPath != m.GetLockfilePath() { + if err := NewLockStore(m.fs, outputPath).Write(lock); err != nil { + return warnings, err + } + } + return warnings, nil +} + +func (m *Manager) RestoreFromLockfile(sourcePath string, prune bool) error { + if sourcePath == "" { + sourcePath = m.GetLockfilePath() + } + sourceStore := NewLockStore(m.fs, sourcePath) + exists, err := sourceStore.Exists() + if err != nil { + return fmt.Errorf("failed to check plugin lockfile: %w", err) + } + if !exists { + return fmt.Errorf("plugin lockfile not found: %s", sourcePath) + } + target, err := sourceStore.Load() + if err != nil { + return err + } + current, err := m.ensureLockfile() + if err != nil { + return err + } + + for _, id := range target.IDs() { + wanted := target.Plugins[id] + installedPath, err := m.findInstalledPath(id) + if err != nil { + return err + } + + if installedPath != "" { + existing, managed := current.Plugins[id] + if !managed { + return fmt.Errorf("plugin %q is already installed but is not managed by the lockfile", id) + } + if existing.Repo != wanted.Repo || existing.Path != wanted.Path { + if err := m.UninstallByIDOrName(id); err != nil { + return err + } + installedPath = "" + } + } + + plugin := Plugin{ID: id, Name: id, Repo: wanted.Repo, Path: wanted.Path} + newlyInstalled := installedPath == "" + if installedPath == "" { + if err := m.Install(plugin); err != nil { + return fmt.Errorf("failed to restore plugin %q: %w", id, err) + } + } + + repoPath := m.repositoryPath(id, wanted) + previousCommit, err := m.gitClient.CurrentRevision(repoPath) + if err != nil { + if newlyInstalled { + m.UninstallByIDOrName(id) //nolint:errcheck + } + return fmt.Errorf("failed to read current revision for plugin %q: %w", id, err) + } + if err := m.gitClient.CheckoutRevision(repoPath, wanted.Commit); err != nil { + if newlyInstalled { + if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil { + return fmt.Errorf("failed to restore plugin %q at %s: %w (also failed to remove the partial install: %v)", id, wanted.Commit, err, rollbackErr) + } + } + return fmt.Errorf("failed to restore plugin %q at %s: %w", id, wanted.Commit, err) + } + rollback := func(restoreErr error) error { + if newlyInstalled { + if rollbackErr := m.UninstallByIDOrName(id); rollbackErr != nil { + return fmt.Errorf("%w (also failed to remove the partial install: %v)", restoreErr, rollbackErr) + } + return restoreErr + } + if rollbackErr := m.gitClient.CheckoutRevision(repoPath, previousCommit); rollbackErr != nil { + return fmt.Errorf("%w (also failed to restore revision %s: %v)", restoreErr, previousCommit, rollbackErr) + } + return restoreErr + } + pluginPath := installedPath + if pluginPath == "" { + pluginPath = filepath.Join(m.pluginsDir, id) + } + if actualID := m.getPluginID(pluginPath); actualID != id { + return rollback(fmt.Errorf("restored plugin %q has manifest id %q", id, actualID)) + } + if err := m.recordInstalledPlugin(plugin, repoPath); err != nil { + return rollback(err) + } + current, err = m.lockStore().Load() + if err != nil { + return err + } + } + + if prune { + for _, id := range current.IDs() { + if _, wanted := target.Plugins[id]; wanted { + continue + } + if err := m.UninstallByIDOrName(id); err != nil { + return fmt.Errorf("failed to prune plugin %q: %w", id, err) + } + } + return m.lockStore().Write(target) + } + + merged, err := m.lockStore().Load() + if err != nil { + return err + } + for id, plugin := range target.Plugins { + merged.SetRepositoryCommit(plugin.Repo, plugin.Commit) + merged.Plugins[id] = plugin + } + return m.lockStore().Write(merged) +} + +func (m *Manager) repositoryPath(pluginID string, plugin LockedPlugin) string { + if plugin.Path != "" { + return filepath.Join(m.pluginsDir, ".repos", m.getRepoName(plugin.Repo)) + } + if installedPath, err := m.findInDir(m.pluginsDir, pluginID); err == nil && installedPath != "" { + return installedPath + } + return filepath.Join(m.pluginsDir, pluginID) +} diff --git a/core/internal/plugins/manager_test.go b/core/internal/plugins/manager_test.go index 485479eef..317430d8e 100644 --- a/core/internal/plugins/manager_test.go +++ b/core/internal/plugins/manager_test.go @@ -1,6 +1,7 @@ package plugins import ( + "errors" "os" "path/filepath" "testing" @@ -16,6 +17,7 @@ func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) { manager := &Manager{ fs: fs, pluginsDir: pluginsDir, + lockPath: "/config/plugins.lock.json", gitClient: &mockGitClient{}, } return manager, fs, pluginsDir @@ -245,3 +247,244 @@ func TestManagerGetPluginsDir(t *testing.T) { manager, _, pluginsDir := setupTestManager(t) assert.Equal(t, pluginsDir, manager.GetPluginsDir()) } + +func TestInstallUpdatesLockfile(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"} + manager.gitClient = &mockGitClient{ + cloneFunc: func(path, _ string) error { + require.NoError(t, fs.MkdirAll(path, 0o755)) + return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644) + }, + revisionFunc: func(string) (string, error) { return testCommit, nil }, + } + + require.NoError(t, manager.Install(plugin)) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.Equal(t, LockedPlugin{Repo: plugin.Repo, Commit: testCommit}, lock.Plugins[plugin.ID]) + exists, err := afero.Exists(fs, filepath.Join(pluginsDir, plugin.ID)) + require.NoError(t, err) + assert.True(t, exists) +} + +func TestUpdateRefreshesLockedCommit(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)) + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + plugin.ID: {Repo: plugin.Repo, Commit: testCommit}, + }, + })) + newCommit := "1123456789abcdef0123456789abcdef01234567" + manager.gitClient = &mockGitClient{ + pullFunc: func(string) error { return nil }, + revisionFunc: func(string) (string, error) { return newCommit, nil }, + } + + require.NoError(t, manager.Update(plugin)) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.Equal(t, newCommit, lock.Plugins[plugin.ID].Commit) +} + +func TestUpdateResolvesRenamedPluginDirectory(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"} + pluginPath := filepath.Join(pluginsDir, "renamed-dir") + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)) + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + plugin.ID: {Repo: plugin.Repo, Commit: testCommit}, + }, + })) + var pulledPath string + manager.gitClient = &mockGitClient{ + pullFunc: func(path string) error { + pulledPath = path + return nil + }, + cloneFunc: func(path, _ string) error { + t.Fatalf("unexpected clone to %s", path) + return nil + }, + revisionFunc: func(string) (string, error) { return testCommit, nil }, + } + + require.NoError(t, manager.Update(plugin)) + assert.Equal(t, pluginPath, pulledPath) +} + +func TestUninstallRemovesLockedPlugin(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + plugin := Plugin{ID: "test-plugin", Name: "Test Plugin", Repo: "https://github.com/test/plugin.git"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"test-plugin"}`), 0o644)) + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + plugin.ID: {Repo: plugin.Repo, Commit: testCommit}, + }, + })) + + require.NoError(t, manager.Uninstall(plugin)) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.NotContains(t, lock.Plugins, plugin.ID) +} + +func TestRestoreFromLockfileInstallsExactCommit(t *testing.T) { + manager, fs, _ := setupTestManager(t) + wantedCommit := "2123456789abcdef0123456789abcdef01234567" + incoming := PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + "restored": {Repo: "https://github.com/test/restored.git", Commit: wantedCommit}, + }, + } + require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming)) + currentCommit := testCommit + manager.gitClient = &mockGitClient{ + cloneFunc: func(path, _ string) error { + require.NoError(t, fs.MkdirAll(path, 0o755)) + return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"restored"}`), 0o644) + }, + revisionFunc: func(string) (string, error) { return currentCommit, nil }, + checkoutFunc: func(_ string, revision string) error { + currentCommit = revision + return nil + }, + } + + require.NoError(t, manager.RestoreFromLockfile("/incoming.lock.json", false)) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.Equal(t, wantedCommit, lock.Plugins["restored"].Commit) +} + +func TestSnapshotLockfileMigratesLegacyMonorepoMetadata(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + pluginPath := filepath.Join(pluginsDir, "legacy") + repoPath := filepath.Join(pluginsDir, ".repos", "legacy-repo") + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, fs.MkdirAll(repoPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"legacy"}`), 0o644)) + require.NoError(t, afero.WriteFile(fs, pluginPath+".meta", []byte("repo=https://github.com/test/suite.git\npath=plugins/legacy\nrepodir=legacy-repo"), 0o644)) + manager.gitClient = &mockGitClient{ + revisionFunc: func(path string) (string, error) { + assert.Equal(t, repoPath, path) + return testCommit, nil + }, + } + + lock, warnings, err := manager.SnapshotLockfile() + require.NoError(t, err) + assert.Empty(t, warnings) + assert.Equal(t, LockedPlugin{ + Repo: "https://github.com/test/suite.git", + Path: "plugins/legacy", + Commit: testCommit, + }, lock.Plugins["legacy"]) +} + +func TestRecordInstalledPluginUpdatesSharedRepositoryCommit(t *testing.T) { + manager, _, _ := setupTestManager(t) + repo := "https://github.com/test/suite.git" + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + "one": {Repo: repo, Path: "plugins/one", Commit: testCommit}, + "two": {Repo: repo, Path: "plugins/two", Commit: testCommit}, + }, + })) + newCommit := "3123456789abcdef0123456789abcdef01234567" + manager.gitClient = &mockGitClient{ + revisionFunc: func(string) (string, error) { return newCommit, nil }, + } + + require.NoError(t, manager.recordInstalledPlugin(Plugin{ID: "one", Repo: repo, Path: "plugins/one"}, "/repo")) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.Equal(t, newCommit, lock.Plugins["one"].Commit) + assert.Equal(t, newCommit, lock.Plugins["two"].Commit) +} + +func TestRestoreFromLockfilePrunesManagedPlugins(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + pluginPath := filepath.Join(pluginsDir, "old") + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"old","name":"Old"}`), 0o644)) + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + "old": {Repo: "https://github.com/test/old.git", Commit: testCommit}, + }, + })) + require.NoError(t, NewLockStore(fs, "/empty.lock.json").Write(NewPluginLockfile())) + + require.NoError(t, manager.RestoreFromLockfile("/empty.lock.json", true)) + exists, err := afero.Exists(fs, pluginPath) + require.NoError(t, err) + assert.False(t, exists) + lock, err := manager.lockStore().Load() + require.NoError(t, err) + assert.Empty(t, lock.Plugins) +} + +func TestRestoreMissingLockfileDoesNotPrune(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + pluginPath := filepath.Join(pluginsDir, "kept") + require.NoError(t, fs.MkdirAll(pluginPath, 0o755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(pluginPath, "plugin.json"), []byte(`{"id":"kept"}`), 0o644)) + require.NoError(t, manager.lockStore().Write(PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + "kept": {Repo: "https://github.com/test/kept.git", Commit: testCommit}, + }, + })) + + err := manager.RestoreFromLockfile("/missing.lock.json", true) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + exists, statErr := afero.Exists(fs, pluginPath) + require.NoError(t, statErr) + assert.True(t, exists) +} + +func TestRestoreFailedRevisionRemovesPartialInstall(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + incoming := PluginLockfile{ + LockfileVersion: 1, + Plugins: map[string]LockedPlugin{ + "broken": {Repo: "https://github.com/test/broken.git", Commit: testCommit}, + }, + } + require.NoError(t, NewLockStore(fs, "/incoming.lock.json").Write(incoming)) + manager.gitClient = &mockGitClient{ + cloneFunc: func(path, _ string) error { + require.NoError(t, fs.MkdirAll(path, 0o755)) + return afero.WriteFile(fs, filepath.Join(path, "plugin.json"), []byte(`{"id":"broken"}`), 0o644) + }, + revisionFunc: func(string) (string, error) { return testCommit, nil }, + checkoutFunc: func(string, string) error { + return errors.New("revision unavailable") + }, + } + + err := manager.RestoreFromLockfile("/incoming.lock.json", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "revision unavailable") + exists, statErr := afero.Exists(fs, filepath.Join(pluginsDir, "broken")) + require.NoError(t, statErr) + assert.False(t, exists) + lock, loadErr := manager.lockStore().Load() + require.NoError(t, loadErr) + assert.NotContains(t, lock.Plugins, "broken") +} diff --git a/core/internal/plugins/registry.go b/core/internal/plugins/registry.go index fda1c31ce..7ef0fcf21 100644 --- a/core/internal/plugins/registry.go +++ b/core/internal/plugins/registry.go @@ -10,6 +10,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/registries" "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" "github.com/spf13/afero" ) @@ -35,6 +36,8 @@ type GitClient interface { Pull(path string) error OriginURL(path string) (string, error) HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error) + CurrentRevision(path string) (string, error) + CheckoutRevision(path string, revision string) error } type realGitClient struct{} @@ -135,6 +138,47 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) { return localHash != remoteHead, localHash, remoteHead, nil } +func (g *realGitClient) CurrentRevision(path string) (string, error) { + repo, err := git.PlainOpen(path) + if err != nil { + return "", err + } + head, err := repo.Head() + if err != nil { + return "", err + } + return head.Hash().String(), nil +} + +func (g *realGitClient) CheckoutRevision(path string, revision string) error { + if !gitCommitPattern.MatchString(revision) { + return fmt.Errorf("invalid git revision %q", revision) + } + + repo, err := git.PlainOpen(path) + if err != nil { + return err + } + hash := plumbing.NewHash(revision) + if _, objectErr := repo.CommitObject(hash); objectErr != nil { + if _, remoteErr := repo.Remote("origin"); remoteErr != nil { + return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr) + } + fetchErr := repo.Fetch(&git.FetchOptions{}) + if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate { + return fmt.Errorf("failed to fetch locked revision: %w", fetchErr) + } + } + if _, err := repo.CommitObject(hash); err != nil { + return fmt.Errorf("revision %s is not available in repository: %w", revision, err) + } + worktree, err := repo.Worktree() + if err != nil { + return err + } + return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset}) +} + type Registry struct { fs afero.Fs cacheDir string diff --git a/core/internal/plugins/registry_test.go b/core/internal/plugins/registry_test.go index 3eb2a6204..cba4e0924 100644 --- a/core/internal/plugins/registry_test.go +++ b/core/internal/plugins/registry_test.go @@ -3,10 +3,14 @@ package plugins import ( "encoding/json" "errors" + "os" "path/filepath" "testing" + "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/registries" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,6 +23,8 @@ type mockGitClient struct { pullFunc func(path string) error originFunc func(path string) (string, error) hasUpdatesFunc func(path string) (bool, string, string, error) + revisionFunc func(path string) (string, error) + checkoutFunc func(path string, revision string) error } func (m *mockGitClient) PlainClone(path string, url string) error { @@ -39,7 +45,7 @@ func (m *mockGitClient) OriginURL(path string) (string, error) { if m.originFunc != nil { return m.originFunc(path) } - return "", errors.New("not a repository") + return "https://github.com/test/plugin.git", nil } func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) { @@ -49,6 +55,20 @@ func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) { return false, "", "", nil } +func (m *mockGitClient) CurrentRevision(path string) (string, error) { + if m.revisionFunc != nil { + return m.revisionFunc(path) + } + return "0123456789abcdef0123456789abcdef01234567", nil +} + +func (m *mockGitClient) CheckoutRevision(path string, revision string) error { + if m.checkoutFunc != nil { + return m.checkoutFunc(path, revision) + } + return nil +} + func TestNewRegistry(t *testing.T) { registry, err := NewRegistry() assert.NoError(t, err) @@ -63,6 +83,41 @@ func TestGetCacheDir(t *testing.T) { assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry") } +func TestRealGitClientRevisionCheckout(t *testing.T) { + repoPath := t.TempDir() + repo, err := git.PlainInit(repoPath, false) + require.NoError(t, err) + worktree, err := repo.Worktree() + require.NoError(t, err) + filePath := filepath.Join(repoPath, "value.txt") + signature := &object.Signature{Name: "DMS Test", Email: "test@example.com", When: time.Unix(1, 0)} + + require.NoError(t, os.WriteFile(filePath, []byte("one"), 0o644)) + _, err = worktree.Add("value.txt") + require.NoError(t, err) + first, err := worktree.Commit("first", &git.CommitOptions{Author: signature}) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(filePath, []byte("two"), 0o644)) + _, err = worktree.Add("value.txt") + require.NoError(t, err) + second, err := worktree.Commit("second", &git.CommitOptions{Author: signature}) + require.NoError(t, err) + + client := &realGitClient{} + revision, err := client.CurrentRevision(repoPath) + require.NoError(t, err) + assert.Equal(t, second.String(), revision) + require.NoError(t, client.CheckoutRevision(repoPath, first.String())) + + revision, err = client.CurrentRevision(repoPath) + require.NoError(t, err) + assert.Equal(t, first.String(), revision) + data, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, "one", string(data)) +} + func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) { fs := afero.NewMemMapFs() tmpDir := "/test-cache"