feat(plugins): add portable plugin lockfile (#2976)

* feat(plugins): add portable plugin lockfile

* plugins/lockfile: allow ssh remotes

---------

Co-authored-by: bbedward <[email protected]>
This commit is contained in:
Artrixandbbedward authored and GitHub committed 2026-08-07 10:55:57 -04:00
1 parent a575363f89
commit b7ec57a534
12 files changed
+1171 -93

No files matched your search

+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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 <command>` - 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)
+66
View File
@@ -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 {
+1 -1
View File
@@ -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()...)
+1 -1
View File
@@ -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)
+197
View File
@@ -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
}
+111
View File
@@ -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://[email protected]/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:[email protected]/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://[email protected]/user/plugin.git",
"[email protected]: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)
}
+147 -88
View File
@@ -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
+299
View File
@@ -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)
}
+243
View File
@@ -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")
}
+44
View File
@@ -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
+56 -1
View File
@@ -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: "[email protected]", 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"