{{ message }}
fix: make plugin update atomic + skip submodule recursion - #15
Merged
Conversation
plugin update used to call Remove() then Install() in sequence. If the
Install step failed (network error, git clone failure, disk full), the
old version was already gone — symlinks removed, cache deleted, install
record wiped — leaving the user with no plugin and no recovery path
except manual reinstall.
Replace with installer.Update() which does a two-phase commit:
Stage 1 (materialize): rename old cache to .update-backup, then
clone/copy the new version into the original cache path. If this
fails, rename .update-backup back — old plugin fully preserved.
Stage 2 (swap): remove old symlinks/MCP, create new symlinks/MCP,
overwrite install record. Local filesystem ops only.
Stage 3 (cleanup): remove .update-backup and any other stale version
caches via CleanupOldVersions.
EvalSymlinks the old cache path before the rename so RemoveSymlinks
can lexically match symlink targets (macOS resolves /var to /private/var
when symlinks are created, but the rename breaks later EvalSymlinks).
Add 4 tests covering version bump + symlink swap, materialize-failure
rollback, same-version content refresh, and disabled-state preservation.
Some plugin repos declare submodules with SSH URLs (e.g. superpowers vendors prime-radiant-inc/superpowers-evals via git@github.com:...). go-git's PlainClone with RecurseSubmodules tried to fetch those via its built-in SSH client, which has no access to ssh-agent and failed with 'ssh: handshake failed: ssh: unable to authenticate'. The whole plugin clone failed, blocking updates. Plugin runtime content (skills/commands/agents/MCP) never lives in submodules — they are typically evals or test fixtures. Drop RecurseSubmodules from cloneGitSource and cloneGitSubdirSource so the default NoRecurseSubmodules applies.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Two related fixes that make
plugin updaterobust against failures that previously left users with no plugin and no recovery path.1. Atomic plugin update with rollback on download failure
Problem:
plugin updateranRemove()thenInstall()in sequence. IfInstallfailed (network error, git clone failure, disk full), the old version was already gone — symlinks removed, cache deleted, install record wiped. The user had to manually reinstall.Fix: Replace with
installer.Update()which does a two-phase commit:.update-backup, clone/copy new version into the original path.update-backupback; old plugin fully preserved.update-backup,CleanupOldVersionsfor stale siblingsNetwork I/O only happens in Stage 1, before any destructive operation. Verified live: superpowers SSH-auth failure during update rolled back cleanly (
✓ Rolled back to previous version).Detail:
EvalSymlinks(oldCachePath)is captured before the rename, because symlink targets are stored in evaluated form (macOS/var→/private/var) and the rename breaks laterEvalSymlinkslookups inRemoveSymlinks.2. Skip submodule recursion when cloning plugin source
Problem:
superpowersrepo declares a submodule with an SSH URL:RecurseSubmodules: git.DefaultSubmoduleRecursionDepthmade go-git try to clone that submodule via its built-in SSH client (no ssh-agent access) →ssh: handshake failed→ entire plugin update failed.Fix: Drop
RecurseSubmodulesfromcloneGitSourceandcloneGitSubdirSource. Default (NoRecurseSubmodules) applies. Plugin runtime content (skills/commands/agents/MCP) never lives in submodules — they are typically evals/test fixtures.Verification
go test ./...— all pass, including e2ego vet ./...,go build ./...— cleanplugin updateon 5 plugins — superpowers rolled back on SSH failure (pre-fix behavior was data loss), then updated cleanly after submodule fix landed (14 skills installed)Test plan
go test ./internal/plugin/...passes locallyopencode-plugin plugin update <some-plugin>with healthy source — version bumps, old cache cleanedopencode-plugin plugin update <some-plugin>with broken source (delete source dir or simulate network failure) — old plugin still works after erroropencode-plugin plugin update(bulk) — continues past failures, reportsUpdated N, M failed