# Self-Update Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a "newer version available" check to both the Avalonia app (banner + Help menu manual trigger) and the WPF installer (self-replacement before running the wizard). **Architecture:** A new `ClaudeDo.Releases` library consolidates Gitea API access, checksum verification, version comparison, and installer self-update logic. The installer runs `SelfUpdater` before showing its wizard. The app runs `UpdateCheckService` on startup; a banner in `MainWindow` and a `Help → Check for updates` menu item expose status; clicking **Update now** launches the installed `ClaudeDo.Installer.exe` and closes the app. **Tech Stack:** .NET 8, Avalonia 12 (UI), WPF (Installer), CommunityToolkit.Mvvm, xUnit, `Microsoft.Extensions.DependencyInjection`. **Spec:** `docs/superpowers/specs/2026-04-23-self-update-design.md` **Build note:** The root solution `ClaudeDo.slnx` does not build on .NET 8 (known issue — needs .NET 9). Always build and test against individual `.csproj` files, not the solution. --- ## File Structure **New:** - `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` - `src/ClaudeDo.Releases/IReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/IReleaseClient.cs` - `src/ClaudeDo.Releases/ReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/ReleaseClient.cs` - `src/ClaudeDo.Releases/ChecksumVerifier.cs` — moved from `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs` - `src/ClaudeDo.Releases/VersionComparer.cs` - `src/ClaudeDo.Releases/SelfUpdater.cs` - `src/ClaudeDo.Releases/SelfUpdateResult.cs` - `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj` - `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs` - `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs` - `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests` - `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests` - `src/ClaudeDo.Ui/Services/UpdateCheckService.cs` - `src/ClaudeDo.Ui/Services/InstallerLocator.cs` - `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs` - `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs` **Modified:** - `ClaudeDo.slnx` — add `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` and `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`. - `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add `ProjectReference` to `ClaudeDo.Releases`. - `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use `VersionComparer` from `ClaudeDo.Releases`; update namespace imports after the type moves. - `src/ClaudeDo.Installer/App.xaml.cs` — run `SelfUpdater` before the wizard; handle `--replace-self` argument. - `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — add `ProjectReference` to `ClaudeDo.Releases`. - `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner above content; Help menu in the titlebar. - `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — wire `UpdateCheckService`, expose `IsBannerVisible`, `LatestVersion`, `InlineStatusText`, `CheckForUpdatesCommand`, `UpdateNowCommand`, `DismissBannerCommand`. - `src/ClaudeDo.App/Program.cs` — register `IReleaseClient`, `HttpClient` (via `IHttpClientFactory` or a single-instance singleton), `UpdateCheckService`, `InstallerLocator`. - `docs/open.md` — manual verification checklist entries. --- ## Task 1: Create `ClaudeDo.Releases` project skeleton **Files:** - Create: `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` - Modify: `ClaudeDo.slnx` - Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` - [ ] **Step 1: Create the csproj** Write `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`: ```xml net8.0 enable enable latest ``` - [ ] **Step 2: Add to solution** Edit `ClaudeDo.slnx` — add the new `` line under the `/src/` folder: ```xml ``` - [ ] **Step 3: Add reference from Installer** Edit `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add inside the existing `` that holds `` (or create one): ```xml ``` - [ ] **Step 4: Build to confirm skeleton compiles** Run: `dotnet build src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` Expected: `Build succeeded.` Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` Expected: `Build succeeded.` (still consumes types from its own `Core/` folder — nothing moved yet) - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Releases/ClaudeDo.Releases.csproj src/ClaudeDo.Installer/ClaudeDo.Installer.csproj ClaudeDo.slnx git commit -m "feat(releases): add empty ClaudeDo.Releases library" ``` --- ## Task 2: Move release-API + checksum types into `ClaudeDo.Releases` **Files:** - Create: `src/ClaudeDo.Releases/IReleaseClient.cs` - Create: `src/ClaudeDo.Releases/ReleaseClient.cs` - Create: `src/ClaudeDo.Releases/ChecksumVerifier.cs` - Delete: `src/ClaudeDo.Installer/Core/IReleaseClient.cs` - Delete: `src/ClaudeDo.Installer/Core/ReleaseClient.cs` - Delete: `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs` - [ ] **Step 1: Move `IReleaseClient.cs` to Releases** Create `src/ClaudeDo.Releases/IReleaseClient.cs` with the exact content of `src/ClaudeDo.Installer/Core/IReleaseClient.cs` but change the namespace from `ClaudeDo.Installer.Core` to `ClaudeDo.Releases`: ```csharp namespace ClaudeDo.Releases; public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size); public sealed record GiteaRelease( string TagName, string Name, IReadOnlyList Assets); public interface IReleaseClient { Task GetLatestReleaseAsync(CancellationToken ct); Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct); } ``` - [ ] **Step 2: Move `ReleaseClient.cs` to Releases** Create `src/ClaudeDo.Releases/ReleaseClient.cs` with the content of `src/ClaudeDo.Installer/Core/ReleaseClient.cs`, changing the namespace to `ClaudeDo.Releases`. The rest of the file is unchanged (same HTTP logic, same `DefaultApiBase`). - [ ] **Step 3: Move `ChecksumVerifier.cs` to Releases** Create `src/ClaudeDo.Releases/ChecksumVerifier.cs` with the content of `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`, changing the namespace to `ClaudeDo.Releases`. No other changes. - [ ] **Step 4: Delete the originals** ```bash rm src/ClaudeDo.Installer/Core/IReleaseClient.cs \ src/ClaudeDo.Installer/Core/ReleaseClient.cs \ src/ClaudeDo.Installer/Core/ChecksumVerifier.cs ``` - [ ] **Step 5: Add `using ClaudeDo.Releases;` to every installer file that referenced these types** Run `grep -l "ClaudeDo.Installer.Core" src/ClaudeDo.Installer --include="*.cs" -r` to find files that use types in `ClaudeDo.Installer.Core`. Inspect each and, wherever the file used `IReleaseClient`, `ReleaseClient`, `GiteaRelease`, `ReleaseAsset`, `ChecksumVerifier`, add `using ClaudeDo.Releases;`. Known call sites (from the current code) — confirm and update each: - `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — uses `IReleaseClient`, `GiteaRelease` - `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — uses `IReleaseClient`, `ChecksumVerifier` - `src/ClaudeDo.Installer/App.xaml.cs` — registers `ReleaseClient` in DI In `InstallModeDetector.cs`, the `DetectedState` record currently returns `GiteaRelease?`. That type is now in `ClaudeDo.Releases`. Keep the record signature identical, just add `using ClaudeDo.Releases;`. - [ ] **Step 6: Build Installer to verify moves** Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` Expected: `Build succeeded.` - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Releases/ src/ClaudeDo.Installer/ git commit -m "refactor(releases): move release-API + checksum types to ClaudeDo.Releases" ``` --- ## Task 3: Create `ClaudeDo.Releases.Tests` and port existing tests **Files:** - Create: `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj` - Create: `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` (port) - Create: `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` (port) - Modify: `ClaudeDo.slnx` - Modify (delete ported tests): `tests/ClaudeDo.Installer.Tests/...` - [ ] **Step 1: Create the test csproj** Write `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`: ```xml net8.0 enable enable false true ``` - [ ] **Step 2: Add to solution** Edit `ClaudeDo.slnx` — add under `/tests/` folder: ```xml ``` - [ ] **Step 3: Identify ported tests** Run: `grep -l -E "ReleaseClient|ChecksumVerifier" tests/ClaudeDo.Installer.Tests/ -r --include="*.cs"` Expected: a list of files (e.g. `ReleaseClientTests.cs`, `ChecksumVerifierTests.cs`). If no match, there are none to port and this task still proceeds — just skip the file copies. - [ ] **Step 4: Copy each matched test file to `tests/ClaudeDo.Releases.Tests/`** For each file from Step 3: 1. Copy to `tests/ClaudeDo.Releases.Tests/`. 2. Change namespace from `ClaudeDo.Installer.Tests...` to `ClaudeDo.Releases.Tests`. 3. Update `using ClaudeDo.Installer.Core;` → `using ClaudeDo.Releases;`. 4. Delete the original file in `tests/ClaudeDo.Installer.Tests/`. - [ ] **Step 5: Build + run the new test project** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj` Expected: all ported tests pass (or `No tests were found` if none were ported — in which case the project still builds and we'll add tests in later tasks). - [ ] **Step 6: Build + run the Installer tests (confirm no broken refs)** Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` Expected: `Build succeeded.` and all remaining tests pass. - [ ] **Step 7: Commit** ```bash git add tests/ClaudeDo.Releases.Tests/ tests/ClaudeDo.Installer.Tests/ ClaudeDo.slnx git commit -m "test(releases): port ReleaseClient + ChecksumVerifier tests to new project" ``` --- ## Task 4: Add `VersionComparer` (TDD) **Files:** - Create: `src/ClaudeDo.Releases/VersionComparer.cs` - Create: `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs` - [ ] **Step 1: Write the failing tests** Write `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`: ```csharp namespace ClaudeDo.Releases.Tests; public class VersionComparerTests { [Theory] [InlineData("0.2.0", "0.1.0", true, false)] [InlineData("0.2.0", "0.2.0", false, false)] [InlineData("0.1.0", "0.2.0", false, false)] [InlineData("v0.2.0", "0.1.0", true, false)] [InlineData("0.2.0", "v0.1.0", true, false)] [InlineData("1.0.0.0", "0.99.99.99", true, false)] public void Compare_ParseableVersions(string latest, string current, bool expectedNewer, bool expectedUnparseable) { var result = VersionComparer.Compare(latest, current); Assert.Equal(expectedNewer, result.IsNewer); Assert.Equal(expectedUnparseable, result.Unparseable); } [Theory] [InlineData("0.2.0-beta", "0.1.0")] [InlineData("0.2.0", "0.1.0-alpha")] [InlineData("garbage", "0.1.0")] [InlineData("", "0.1.0")] public void Compare_UnparseableReturnsNotNewer(string latest, string current) { var result = VersionComparer.Compare(latest, current); Assert.False(result.IsNewer); Assert.True(result.Unparseable); } } ``` - [ ] **Step 2: Run to verify failure** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests` Expected: FAIL — `VersionComparer` does not exist. - [ ] **Step 3: Implement** Write `src/ClaudeDo.Releases/VersionComparer.cs`: ```csharp namespace ClaudeDo.Releases; public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable); public static class VersionComparer { /// /// Returns IsNewer=true only when both versions parse as System.Version AND latest > current. /// Pre-release tags like "0.2.0-beta" fail to parse and are treated as not newer with /// Unparseable=true so callers can surface a hint. /// public static VersionCompareResult Compare(string latest, string current) { var latestTrimmed = (latest ?? "").TrimStart('v', 'V'); var currentTrimmed = (current ?? "").TrimStart('v', 'V'); var unparseable = !Version.TryParse(latestTrimmed, out var lv) | !Version.TryParse(currentTrimmed, out var cv); if (unparseable) return new VersionCompareResult(false, true); return new VersionCompareResult(lv > cv, false); } } ``` - [ ] **Step 4: Run tests to verify pass** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Releases/VersionComparer.cs tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs git commit -m "feat(releases): add VersionComparer" ``` --- ## Task 5: Refactor `InstallModeDetector` to use `VersionComparer` **Files:** - Modify: `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` - [ ] **Step 1: Replace the private `IsNewer` method with a call to `VersionComparer.Compare`** Edit `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`. The existing `DetectAsync` currently calls a private `IsNewer(latestVersion, manifest.Version, out var unparseable)`. Replace the method and its call site: Inside `DetectAsync`, change: ```csharp var newer = IsNewer(latestVersion, manifest.Version, out var unparseable); ``` to: ```csharp var cmp = VersionComparer.Compare(latestVersion, manifest.Version); var newer = cmp.IsNewer; var unparseable = cmp.Unparseable; ``` Delete the entire private `IsNewer` method at the bottom of the file. Ensure `using ClaudeDo.Releases;` is present at the top. - [ ] **Step 2: Build + run existing tests** Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` Expected: all existing `InstallModeDetector` tests still pass — `VersionComparer` preserves identical semantics. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Installer/Core/InstallModeDetector.cs git commit -m "refactor(installer): use shared VersionComparer in InstallModeDetector" ``` --- ## Task 6: Add `SelfUpdater` — installer-asset matching (TDD) **Files:** - Create: `src/ClaudeDo.Releases/SelfUpdater.cs` - Create: `src/ClaudeDo.Releases/SelfUpdateResult.cs` - Create: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs` - [ ] **Step 1: Write failing tests for asset matching** Write `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`: ```csharp namespace ClaudeDo.Releases.Tests; public class SelfUpdaterAssetMatchingTests { [Fact] public void FindInstallerAsset_PicksInstallerExeByPattern() { var assets = new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20), new ReleaseAsset("checksums.txt", "https://x/checks", 1), }; var result = SelfUpdater.FindInstallerAsset(assets); Assert.NotNull(result); Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name); Assert.Equal("0.3.0", result.Version); } [Fact] public void FindInstallerAsset_ReturnsNullWhenAbsent() { var assets = new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), }; Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } [Fact] public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller() { var assets = new[] { new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1), new ReleaseAsset("not-the-installer.exe", "https://x/2", 1), }; Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } } ``` - [ ] **Step 2: Write the minimum stub to make the compiler happy** Write `src/ClaudeDo.Releases/SelfUpdateResult.cs`: ```csharp namespace ClaudeDo.Releases; public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version); ``` Write `src/ClaudeDo.Releases/SelfUpdater.cs`: ```csharp using System.Text.RegularExpressions; namespace ClaudeDo.Releases; public static partial class SelfUpdater { [GeneratedRegex(@"^ClaudeDo\.Installer-(?[\d\.]+)\.exe$", RegexOptions.IgnoreCase)] private static partial Regex InstallerAssetRegex(); public static InstallerAssetMatch? FindInstallerAsset(IEnumerable assets) { foreach (var asset in assets) { var m = InstallerAssetRegex().Match(asset.Name); if (m.Success) { return new InstallerAssetMatch(asset, m.Groups["version"].Value); } } return null; } } ``` - [ ] **Step 3: Run tests to verify pass** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterAssetMatchingTests` Expected: all 3 tests pass. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs git commit -m "feat(releases): add SelfUpdater installer-asset matching" ``` --- ## Task 7: Add `SelfUpdater.DecideUpdateAsync` (TDD) **Files:** - Modify: `src/ClaudeDo.Releases/SelfUpdater.cs` - Modify: `src/ClaudeDo.Releases/SelfUpdateResult.cs` - Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs` - [ ] **Step 1: Add failing decision-logic tests** Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`: ```csharp public class SelfUpdaterDecisionTests { private sealed class FakeReleaseClient : IReleaseClient { public GiteaRelease? Release { get; set; } public bool Throw { get; set; } public Task GetLatestReleaseAsync(CancellationToken ct) { if (Throw) throw new HttpRequestException("boom"); return Task.FromResult(Release); } public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) => throw new NotSupportedException("not used in decision tests"); } [Fact] public async Task Decide_NoRelease_NoUpdate() { var client = new FakeReleaseClient { Release = null }; var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_NetworkError_NoUpdate() { var client = new FakeReleaseClient { Throw = true }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_OlderLatest_NoUpdate() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.1.0", "rel", new[] { new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } [Fact] public async Task Decide_NewerLatestWithAsset_UpdateAvailable() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.3.0", "rel", new[] { new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20), new ReleaseAsset("checksums.txt", "https://checks", 1), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind); Assert.Equal("0.3.0", d.LatestVersion); Assert.NotNull(d.InstallerAsset); Assert.NotNull(d.ChecksumsAsset); } [Fact] public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate() { var client = new FakeReleaseClient { Release = new GiteaRelease("v0.3.0", "rel", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20), }), }; var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } } ``` - [ ] **Step 2: Add return types to `SelfUpdateResult.cs`** Extend `src/ClaudeDo.Releases/SelfUpdateResult.cs`: ```csharp namespace ClaudeDo.Releases; public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version); public enum SelfUpdateDecisionKind { NoUpdate, UpdateAvailable, } public sealed record SelfUpdateDecision( SelfUpdateDecisionKind Kind, string? LatestVersion = null, ReleaseAsset? InstallerAsset = null, ReleaseAsset? ChecksumsAsset = null); ``` - [ ] **Step 3: Implement `DecideUpdateAsync`** Add to `src/ClaudeDo.Releases/SelfUpdater.cs`: ```csharp public static async Task DecideUpdateAsync( IReleaseClient releases, string currentVersion, CancellationToken ct) { GiteaRelease? release; try { release = await releases.GetLatestReleaseAsync(ct); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); } if (release is null) return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); var match = FindInstallerAsset(release.Assets); if (match is null) return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); var cmp = VersionComparer.Compare(match.Version, currentVersion); if (!cmp.IsNewer) return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); var checksums = release.Assets.FirstOrDefault( a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase)); return new SelfUpdateDecision( SelfUpdateDecisionKind.UpdateAvailable, LatestVersion: match.Version, InstallerAsset: match.Asset, ChecksumsAsset: checksums); } ``` Add `using System.Net.Http;` at the top. - [ ] **Step 4: Run tests** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdater` Expected: all SelfUpdater tests pass. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs git commit -m "feat(releases): add SelfUpdater.DecideUpdateAsync" ``` --- ## Task 8: Add `SelfUpdater.HandleReplaceSelfAsync` (TDD) **Purpose:** When the installer is launched with `--replace-self ""`, it waits for the old process to release its file lock, deletes the old exe, copies itself to the old path, relaunches from the old path, and exits. **Files:** - Modify: `src/ClaudeDo.Releases/SelfUpdater.cs` - Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs` - [ ] **Step 1: Write failing tests** Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`: ```csharp public class SelfUpdaterReplaceSelfTests : IDisposable { private readonly string _tempDir; public SelfUpdaterReplaceSelfTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } [Fact] public async Task Replace_DeletesOldAndCopiesCurrent() { var oldPath = Path.Combine(_tempDir, "old.exe"); var currentPath = Path.Combine(_tempDir, "current.exe"); await File.WriteAllTextAsync(oldPath, "OLD"); await File.WriteAllTextAsync(currentPath, "NEW"); var relaunchedWith = ""; var result = await SelfUpdater.HandleReplaceSelfAsync( oldPath: oldPath, currentExePath: currentPath, launchProcess: path => { relaunchedWith = path; return true; }, maxWaitMs: 500); Assert.True(result); Assert.Equal(oldPath, relaunchedWith); Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath)); } [Fact] public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse() { var oldPath = Path.Combine(_tempDir, "locked.exe"); var currentPath = Path.Combine(_tempDir, "current.exe"); await File.WriteAllTextAsync(oldPath, "OLD"); await File.WriteAllTextAsync(currentPath, "NEW"); // Hold an exclusive lock across the wait window. using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); var result = await SelfUpdater.HandleReplaceSelfAsync( oldPath: oldPath, currentExePath: currentPath, launchProcess: _ => true, maxWaitMs: 200); Assert.False(result); } } ``` - [ ] **Step 2: Implement** Add to `src/ClaudeDo.Releases/SelfUpdater.cs`: ```csharp /// /// Called when launched with `--replace-self "<old-path>"`. Waits for the old process /// to release its file lock, deletes the old file, copies the current exe to the old path, /// then calls with that path. Returns true on success. /// public static async Task HandleReplaceSelfAsync( string oldPath, string currentExePath, Func launchProcess, int maxWaitMs = 5000) { var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs); while (DateTime.UtcNow < deadline) { try { if (File.Exists(oldPath)) { File.Delete(oldPath); } break; } catch (IOException) { await Task.Delay(100); } catch (UnauthorizedAccessException) { await Task.Delay(100); } } if (File.Exists(oldPath)) { return false; } File.Copy(currentExePath, oldPath, overwrite: false); return launchProcess(oldPath); } ``` - [ ] **Step 3: Run tests** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterReplaceSelfTests` Expected: both tests pass. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs git commit -m "feat(releases): add SelfUpdater.HandleReplaceSelfAsync" ``` --- ## Task 9: Add `SelfUpdater.DownloadAndVerifyAsync` (TDD) **Purpose:** Download the new installer to `%TEMP%` and verify it against `checksums.txt`. **Files:** - Modify: `src/ClaudeDo.Releases/SelfUpdater.cs` - Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs` - [ ] **Step 1: Write failing tests** Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`: ```csharp public class SelfUpdaterDownloadTests : IDisposable { private readonly string _tempDir; public SelfUpdaterDownloadTests() { _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } private sealed class StubReleaseClient : IReleaseClient { public string FileContent { get; set; } = ""; public string ChecksumsBody { get; set; } = ""; public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(null); public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) { if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase)) { await File.WriteAllTextAsync(destPath, ChecksumsBody, ct); } else { await File.WriteAllTextAsync(destPath, FileContent, ct); } progress.Report(FileContent.Length); } } [Fact] public async Task Download_MatchingChecksum_ReturnsPath() { var content = "FAKE-INSTALLER-BINARY"; var hash = Sha256Hex(content); var client = new StubReleaseClient { FileContent = content, ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n", }; var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length); var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); var path = await SelfUpdater.DownloadAndVerifyAsync( client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); Assert.NotNull(path); Assert.Equal(content, await File.ReadAllTextAsync(path!)); } [Fact] public async Task Download_ChecksumMismatch_ReturnsNull() { var client = new StubReleaseClient { FileContent = "real", ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n", }; var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4); var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); var path = await SelfUpdater.DownloadAndVerifyAsync( client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); Assert.Null(path); } private static string Sha256Hex(string s) { using var sha = System.Security.Cryptography.SHA256.Create(); return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant(); } } ``` - [ ] **Step 2: Implement** Add to `src/ClaudeDo.Releases/SelfUpdater.cs`: ```csharp /// /// Downloads the installer asset + checksums file to , verifies the /// installer's sha256 against the entry in checksums.txt keyed by asset name, and returns the /// path to the verified installer on success. Returns null on download or verification failure. /// public static async Task DownloadAndVerifyAsync( IReleaseClient releases, ReleaseAsset installerAsset, ReleaseAsset checksumsAsset, string tempDir, IProgress progress, CancellationToken ct) { Directory.CreateDirectory(tempDir); var installerPath = Path.Combine(tempDir, installerAsset.Name); var checksumsPath = Path.Combine(tempDir, "checksums.txt"); try { await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct); await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress(_ => { }), ct); } catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException) { return null; } var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct); var map = ChecksumVerifier.ParseChecksumsFile(checksumsText); if (!map.TryGetValue(installerAsset.Name, out var expected)) return null; return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null; } ``` Add `using System.Net.Http;` and `using System.IO;` at the top if not present. - [ ] **Step 3: Run tests** Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterDownloadTests` Expected: both pass. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs git commit -m "feat(releases): add SelfUpdater.DownloadAndVerifyAsync" ``` --- ## Task 10: Wire `SelfUpdater` into Installer `App.xaml.cs` **Purpose:** Before the wizard window shows, (1) handle `--replace-self` if present, otherwise (2) check for a newer installer and prompt the user. **Files:** - Modify: `src/ClaudeDo.Installer/App.xaml.cs` - Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml` - Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs` - [ ] **Step 1: Create the prompt window XAML** Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml`: ```xml