From 0d37473575e8db7dee20336771dbeb2bded648b3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 13:52:36 +0200 Subject: [PATCH] docs(self-update): add implementation plan --- .../plans/2026-04-23-self-update.md | 1831 +++++++++++++++++ 1 file changed, 1831 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-self-update.md diff --git a/docs/superpowers/plans/2026-04-23-self-update.md b/docs/superpowers/plans/2026-04-23-self-update.md new file mode 100644 index 0000000..72a6478 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-self-update.md @@ -0,0 +1,1831 @@ +# 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 + + + + + + + + + + + + +