mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-07 22:18:30 -04:00
b7ec57a534
* feat(plugins): add portable plugin lockfile * plugins/lockfile: allow ssh remotes --------- Co-authored-by: bbedward <bbedward@gmail.com>
300 lines
8.3 KiB
Go
300 lines
8.3 KiB
Go
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)
|
|
}
|