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) }