Files
ClaudeDo/docs/superpowers/plans/2026-04-23-self-update.md
2026-04-23 13:52:36 +02:00

1832 lines
64 KiB
Markdown

# 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
```
- [ ] **Step 2: Add to solution**
Edit `ClaudeDo.slnx` — add the new `<Project>` line under the `/src/` folder:
```xml
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
```
- [ ] **Step 3: Add reference from Installer**
Edit `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add inside the existing `<ItemGroup>` that holds `<ProjectReference>` (or create one):
```xml
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
```
- [ ] **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<ReleaseAsset> Assets);
public interface IReleaseClient
{
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
Task DownloadAsync(string url, string destPath, IProgress<long> 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
</ItemGroup>
</Project>
```
- [ ] **Step 2: Add to solution**
Edit `ClaudeDo.slnx` — add under `/tests/` folder:
```xml
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
```
- [ ] **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/<filename>`.
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
{
/// <summary>
/// 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.
/// </summary>
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-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
private static partial Regex InstallerAssetRegex();
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> 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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
if (Throw) throw new HttpRequestException("boom");
return Task.FromResult(Release);
}
public Task DownloadAsync(string url, string destPath, IProgress<long> 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<SelfUpdateDecision> 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 "<old-path>"`, 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
/// <summary>
/// Called when launched with `--replace-self "&lt;old-path&gt;"`. Waits for the old process
/// to release its file lock, deletes the old file, copies the current exe to the old path,
/// then calls <paramref name="launchProcess"/> with that path. Returns true on success.
/// </summary>
public static async Task<bool> HandleReplaceSelfAsync(
string oldPath,
string currentExePath,
Func<string, bool> 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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);
public async Task DownloadAsync(string url, string destPath, IProgress<long> 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<long>(_ => { }), 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<long>(_ => { }), 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
/// <summary>
/// Downloads the installer asset + checksums file to <paramref name="tempDir"/>, 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.
/// </summary>
public static async Task<string?> DownloadAndVerifyAsync(
IReleaseClient releases,
ReleaseAsset installerAsset,
ReleaseAsset checksumsAsset,
string tempDir,
IProgress<long> 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<long>(_ => { }), 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
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="ClaudeDo Installer Update"
Width="460" Height="180"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
Background="#1a1a1a" Foreground="#f0f0f0">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
<Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
<Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
```
- [ ] **Step 2: Create the prompt window code-behind**
Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs`:
```csharp
using System.Windows;
namespace ClaudeDo.Installer.Views;
public enum SelfUpdateChoice { Update, Continue, Cancel }
public partial class SelfUpdatePromptWindow : Window
{
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
{
InitializeComponent();
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
}
public void ShowProgress(string text)
{
ProgressText.Text = text;
ProgressText.Visibility = Visibility.Visible;
UpdateBtn.IsEnabled = false;
ContinueBtn.IsEnabled = false;
}
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Update;
DialogResult = true;
}
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Continue;
DialogResult = true;
}
private void CancelBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Cancel;
DialogResult = false;
}
}
```
- [ ] **Step 3: Modify `App.xaml.cs` OnStartup to run self-update first**
Edit `src/ClaudeDo.Installer/App.xaml.cs`. Locate `OnStartup`; immediately after `base.OnStartup(e);` (but before service construction and main window show), insert the self-update logic. Replace the relevant top of `OnStartup` with:
```csharp
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// --- Self-update pre-flight ---
var currentExePath = System.Reflection.Assembly.GetEntryAssembly()!.Location;
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
}
// Argument form: --replace-self "<old-path>"
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
{
var oldPath = e.Args[replaceSelfIndex + 1];
var relaunched = await ClaudeDo.Releases.SelfUpdater.HandleReplaceSelfAsync(
oldPath: oldPath,
currentExePath: currentExePath,
launchProcess: path =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return true;
}
catch { return false; }
});
if (relaunched)
{
Shutdown(0);
return;
}
// If replacement failed, fall through to normal wizard from the temp location.
}
else
{
// Normal launch: check for a newer installer.
using var http = new System.Net.Http.HttpClient();
var releases = new ClaudeDo.Releases.ReleaseClient(http);
var currentVersion = InstallContext.GetInstallerVersion();
var decision = await ClaudeDo.Releases.SelfUpdater.DecideUpdateAsync(releases, currentVersion, CancellationToken.None);
if (decision.Kind == ClaudeDo.Releases.SelfUpdateDecisionKind.UpdateAvailable)
{
var prompt = new Views.SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
var ok = prompt.ShowDialog() == true;
if (!ok)
{
Shutdown(0);
return;
}
if (prompt.Choice == Views.SelfUpdateChoice.Update)
{
prompt.ShowProgress("Downloading...");
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
var verifiedPath = await ClaudeDo.Releases.SelfUpdater.DownloadAndVerifyAsync(
releases,
decision.InstallerAsset!,
decision.ChecksumsAsset!,
tempDir,
new Progress<long>(bytes => { /* could update prompt.ProgressText */ }),
CancellationToken.None);
if (verifiedPath is null)
{
MessageBox.Show(prompt, "Update download or verification failed. Continuing with current installer.",
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(verifiedPath)
{
ArgumentList = { "--replace-self", currentExePath },
UseShellExecute = true,
});
Shutdown(0);
return;
}
catch (Exception ex)
{
MessageBox.Show(prompt, "Failed to launch updated installer: " + ex.Message
+ "\nContinuing with current installer.", "ClaudeDo Installer",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
// SelfUpdateChoice.Continue falls through to normal wizard.
}
// Failure / no-update also falls through to normal wizard.
}
// --- Existing wizard start-up unchanged below this line ---
// (Preserve the rest of the original OnStartup body as-is.)
```
> **Note:** If `InstallContext.GetInstallerVersion()` does not yet return a `System.Version`-parseable string, see Step 4.
- [ ] **Step 4: Ensure `InstallContext.GetInstallerVersion()` returns a parseable version**
Open `src/ClaudeDo.Installer/Core/InstallContext.cs` and inspect the existing `GetInstallerVersion()`. If it returns something like `"0.0.0-dev"` or an informational string, add a variant that returns the raw assembly `Version.ToString()` (e.g. `"0.0.0.0"`). If it already returns a numeric `Major.Minor.Build` string, no change is needed.
If a change is required, add (or replace) with:
```csharp
public static string GetInstallerVersion()
{
var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0);
return v.ToString();
}
```
Only change this method if needed — preserve existing behavior if it already returns a parseable version.
- [ ] **Step 5: Build + smoke-test**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: `Build succeeded.`
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Installer/App.xaml.cs src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs src/ClaudeDo.Installer/Core/InstallContext.cs
git commit -m "feat(installer): self-update pre-flight before wizard"
```
---
## Task 11: Add `UpdateCheckService` in `ClaudeDo.Ui.Services`
**Files:**
- Create: `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
- Create: `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`
- Modify: `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
- [ ] **Step 1: Add project reference from Ui**
Edit `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — inside the existing `<ItemGroup>` holding `<ProjectReference>` items (or create one), add:
```xml
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
```
- [ ] **Step 2: Write failing tests**
Write `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`:
```csharp
using ClaudeDo.Releases;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests.Services;
public class UpdateCheckServiceTests
{
private sealed class FakeReleaseClient : IReleaseClient
{
public GiteaRelease? Release { get; set; }
public bool Throw { get; set; }
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
if (Throw) throw new HttpRequestException();
return Task.FromResult(Release);
}
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
=> throw new NotSupportedException();
}
[Fact]
public async Task Check_NewerRelease_SetsUpdateAvailable()
{
var svc = new UpdateCheckService(new FakeReleaseClient
{
Release = new GiteaRelease("v0.3.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 1) }),
},
currentVersion: "0.1.0");
await svc.CheckNowAsync(CancellationToken.None);
Assert.Equal(UpdateCheckStatus.UpdateAvailable, svc.LastCheckStatus);
Assert.True(svc.IsUpdateAvailable);
Assert.Equal("0.3.0", svc.LatestVersion);
}
[Fact]
public async Task Check_SameRelease_SetsUpToDate()
{
var svc = new UpdateCheckService(new FakeReleaseClient
{
Release = new GiteaRelease("v0.1.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "u", 1) }),
},
currentVersion: "0.1.0");
await svc.CheckNowAsync(CancellationToken.None);
Assert.Equal(UpdateCheckStatus.UpToDate, svc.LastCheckStatus);
Assert.False(svc.IsUpdateAvailable);
}
[Fact]
public async Task Check_NetworkError_SetsCheckFailedButDoesNotThrow()
{
var svc = new UpdateCheckService(new FakeReleaseClient { Throw = true }, "0.1.0");
await svc.CheckNowAsync(CancellationToken.None);
Assert.Equal(UpdateCheckStatus.CheckFailed, svc.LastCheckStatus);
}
}
```
- [ ] **Step 3: Implement `UpdateCheckService`**
Write `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`:
```csharp
using ClaudeDo.Releases;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.Services;
public enum UpdateCheckStatus
{
NeverChecked,
CheckFailed,
UpToDate,
UpdateAvailable,
}
public sealed partial class UpdateCheckService : ObservableObject
{
private readonly IReleaseClient _releases;
[ObservableProperty] private bool _isUpdateAvailable;
[ObservableProperty] private string? _latestVersion;
[ObservableProperty] private string _currentVersion;
[ObservableProperty] private bool _isChecking;
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
public UpdateCheckService(IReleaseClient releases, string currentVersion)
{
_releases = releases;
_currentVersion = currentVersion;
}
public async Task CheckNowAsync(CancellationToken ct)
{
IsChecking = true;
try
{
GiteaRelease? rel;
try
{
rel = await _releases.GetLatestReleaseAsync(ct);
}
catch
{
LastCheckStatus = UpdateCheckStatus.CheckFailed;
IsUpdateAvailable = false;
return;
}
if (rel is null)
{
LastCheckStatus = UpdateCheckStatus.CheckFailed;
IsUpdateAvailable = false;
return;
}
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
var cmp = VersionComparer.Compare(latest, CurrentVersion);
if (cmp.IsNewer)
{
LatestVersion = latest;
IsUpdateAvailable = true;
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
}
else
{
IsUpdateAvailable = false;
LastCheckStatus = UpdateCheckStatus.UpToDate;
}
}
finally
{
IsChecking = false;
}
}
}
```
- [ ] **Step 4: Run tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter UpdateCheckServiceTests`
Expected: all pass.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/UpdateCheckService.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/
git commit -m "feat(ui): add UpdateCheckService"
```
---
## Task 12: Add `InstallerLocator` in `ClaudeDo.Ui.Services`
**Files:**
- Create: `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
- Create: `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`
- [ ] **Step 1: Write failing tests**
Write `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests.Services;
public class InstallerLocatorTests : IDisposable
{
private readonly string _root;
public InstallerLocatorTests()
{
_root = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
public void Dispose() { try { Directory.Delete(_root, true); } catch { } }
[Fact]
public void Find_WalkUpFromAppDir_ToInstallJsonSibling()
{
var installDir = Path.Combine(_root, "ClaudeDo");
var appDir = Path.Combine(installDir, "app");
var uninstallerDir = Path.Combine(installDir, "uninstaller");
Directory.CreateDirectory(appDir);
Directory.CreateDirectory(uninstallerDir);
File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
var installerPath = Path.Combine(uninstallerDir, "ClaudeDo.Installer.exe");
File.WriteAllText(installerPath, "x");
var locator = new InstallerLocator();
var found = locator.FindByWalkingUp(appDir);
Assert.Equal(installerPath, found);
}
[Fact]
public void Find_ReturnsNullWhenNoInstallJson()
{
var appDir = Path.Combine(_root, "somewhere", "app");
Directory.CreateDirectory(appDir);
var locator = new InstallerLocator();
Assert.Null(locator.FindByWalkingUp(appDir));
}
[Fact]
public void Find_ReturnsNullWhenInstallerMissingFromUninstallerDir()
{
var installDir = Path.Combine(_root, "ClaudeDo");
var appDir = Path.Combine(installDir, "app");
Directory.CreateDirectory(appDir);
Directory.CreateDirectory(Path.Combine(installDir, "uninstaller"));
File.WriteAllText(Path.Combine(installDir, "install.json"), "{}");
var locator = new InstallerLocator();
Assert.Null(locator.FindByWalkingUp(appDir));
}
}
```
- [ ] **Step 2: Implement**
Write `src/ClaudeDo.Ui/Services/InstallerLocator.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed class InstallerLocator
{
private const string InstallJson = "install.json";
private const string InstallerExe = "ClaudeDo.Installer.exe";
private const string UninstallerSubdir = "uninstaller";
/// <summary>
/// Convenience entry point for production callers: starts from AppContext.BaseDirectory
/// and falls back to registry if the walk fails.
/// </summary>
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var manifest = Path.Combine(dir.FullName, InstallJson);
if (File.Exists(manifest))
{
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
catch
{
return null;
}
}
}
```
If the Ui csproj does not already pull in `Microsoft.Win32.Registry`, add:
```xml
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
```
(Windows-only runtime reference; Avalonia can ship cross-platform so gate usage with `OperatingSystem.IsWindows()` as shown.)
- [ ] **Step 3: Run tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter InstallerLocatorTests`
Expected: all pass.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/InstallerLocator.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
git commit -m "feat(ui): add InstallerLocator"
```
---
## Task 13: Wire new services into `ClaudeDo.App/Program.cs` DI
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1: Add new service registrations**
Edit `src/ClaudeDo.App/Program.cs`. Inside `BuildServices()` (or whatever method builds the `ServiceCollection`), register:
```csharp
sc.AddSingleton<System.Net.Http.HttpClient>();
sc.AddSingleton<ClaudeDo.Releases.IReleaseClient>(sp =>
new ClaudeDo.Releases.ReleaseClient(sp.GetRequiredService<System.Net.Http.HttpClient>()));
sc.AddSingleton<ClaudeDo.Ui.Services.InstallerLocator>();
sc.AddSingleton(sp =>
{
var releases = sp.GetRequiredService<ClaudeDo.Releases.IReleaseClient>();
var version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0";
return new ClaudeDo.Ui.Services.UpdateCheckService(releases, version);
});
```
Place the registrations alongside existing singleton registrations. Keep the order logical: `HttpClient``IReleaseClient` → services that depend on them.
- [ ] **Step 2: Inject `UpdateCheckService` and `InstallerLocator` into `IslandsShellViewModel`**
Locate `IslandsShellViewModel`'s constructor. Add parameters for both services and forward them to fields. Also update the `IslandsShellViewModel` DI registration in `Program.cs` so that these are resolved through the container (normally Microsoft DI does this automatically as long as the ViewModel is registered with `AddSingleton<IslandsShellViewModel>()` or similar — verify by inspecting the existing registration).
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: `Build succeeded.`
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(app): register UpdateCheckService and InstallerLocator in DI"
```
---
## Task 14: Extend `IslandsShellViewModel` with update-check state + commands
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- [ ] **Step 1: Add fields, properties, and commands**
At the top of `IslandsShellViewModel`, add (or extend) fields:
```csharp
private readonly UpdateCheckService _updateCheck;
private readonly InstallerLocator _installerLocator;
[ObservableProperty] private bool _isUpdateBannerVisible;
[ObservableProperty] private string? _updateBannerLatestVersion;
[ObservableProperty] private string? _inlineUpdateStatus; // short message shown briefly
private bool _bannerDismissedThisSession;
```
Add `using ClaudeDo.Ui.Services;` and `using CommunityToolkit.Mvvm.ComponentModel;` if not already present.
- [ ] **Step 2: Subscribe to update service**
In the constructor, after assigning fields:
```csharp
_updateCheck.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
{
RefreshBannerFromStatus();
}
};
// Fire-and-forget startup check — never block UI on this.
_ = Task.Run(async () =>
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
```
And the helper:
```csharp
private void RefreshBannerFromStatus()
{
switch (_updateCheck.LastCheckStatus)
{
case UpdateCheckStatus.UpdateAvailable:
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
IsUpdateBannerVisible = true;
InlineUpdateStatus = null;
break;
case UpdateCheckStatus.UpToDate:
IsUpdateBannerVisible = false;
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
break;
case UpdateCheckStatus.CheckFailed:
ShowInlineStatus("Could not check for updates");
break;
}
}
private async void ShowInlineStatus(string text)
{
InlineUpdateStatus = text;
await Task.Delay(3000);
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
}
```
- [ ] **Step 3: Add commands**
Add three commands:
```csharp
[RelayCommand]
private async Task CheckForUpdatesAsync()
{
await _updateCheck.CheckNowAsync(CancellationToken.None);
}
[RelayCommand]
private void DismissBanner()
{
_bannerDismissedThisSession = true;
IsUpdateBannerVisible = false;
}
[RelayCommand]
private void UpdateNow()
{
var path = _installerLocator.Find();
if (path is null) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
Environment.Exit(0); // close the app so the installer can replace its files
}
catch
{
// Intentionally silent — if this fails there's not much we can do from UI.
}
}
```
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: `Build succeeded.`
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(ui): wire update-check state and commands into shell VM"
```
---
## Task 15: Add banner + Help menu to `MainWindow.axaml`
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- [ ] **Step 1: Adjust the Grid RowDefinitions to leave room for a banner**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, change:
```xml
<Grid RowDefinitions="36,*,22">
```
to:
```xml
<Grid RowDefinitions="36,Auto,*,22">
```
Then shift every child element in Row 1 (content) and Row 2 (status bar) down by one: `Grid.Row="1"``Grid.Row="2"`, `Grid.Row="2"``Grid.Row="3"`. Be systematic — search the file for `Grid.Row="1"` and `Grid.Row="2"` and update every occurrence.
- [ ] **Step 2: Insert the banner at `Grid.Row="1"`**
Add (at the appropriate place, sibling to the titlebar and content rows):
```xml
<Border Grid.Row="1"
Background="{DynamicResource AccentSoftBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="12,6"
IsVisible="{Binding IsUpdateBannerVisible}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}">
<Run Text="Update available: v"/>
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
<Run Text=" → v"/>
<Run Text="{Binding UpdateBannerLatestVersion}"/>
</TextBlock>
<Button Grid.Column="1" Margin="0,0,8,0"
Content="Update now"
Command="{Binding UpdateNowCommand}"/>
<Button Grid.Column="2"
Content="Dismiss"
Command="{Binding DismissBannerCommand}"/>
</Grid>
</Border>
<TextBlock Grid.Row="1" HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,12,0"
Foreground="{DynamicResource TextFaintBrush}"
Text="{Binding InlineUpdateStatus}"
IsVisible="{Binding InlineUpdateStatus, Converter={StaticResource StringNotEmptyConverter}}"/>
```
If `StringNotEmptyConverter` does not exist, use a simple `TextBlock` bound to `InlineUpdateStatus` and rely on empty text being invisible-ish (set `IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"` — Avalonia provides `ObjectConverters.IsNotNull` in `Avalonia.Data.Converters`).
The VM field for the banner's "current version" text exposes `UpdateCheck.CurrentVersion` — add an exposed property on `IslandsShellViewModel`: `public UpdateCheckService UpdateCheck => _updateCheck;` so the XAML can bind to nested properties.
- [ ] **Step 3: Add Help menu to the titlebar**
Locate the titlebar row (`Grid.Row="0"`). The current titlebar has `ColumnDefinitions="Auto,*,Auto"` — add a menu in the middle (column 1) or as a new element in column 0 after the existing app-title content. A safe insertion: in the leftmost column next to the app icon/title, add:
```xml
<Menu Grid.Column="0" Margin="12,0,0,0" VerticalAlignment="Center"
Background="Transparent">
<MenuItem Header="Help">
<MenuItem Header="Check for updates" Command="{Binding CheckForUpdatesCommand}"/>
</MenuItem>
</Menu>
```
Placement is flexible — pick a spot that matches the existing titlebar layout (you may need to read the current titlebar `StackPanel`/`Grid` around line 30-60 of the file). Prefer adding the menu to whichever column already holds titlebar controls.
- [ ] **Step 4: Add a property on `IslandsShellViewModel` exposing the service**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, add:
```csharp
public UpdateCheckService UpdateCheck => _updateCheck;
```
- [ ] **Step 5: Build + run the app manually**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: `Build succeeded.`
Run: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: the app launches; Help menu is visible in the titlebar; no banner shown unless a newer release actually exists in Gitea.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "feat(ui): add update banner and Help menu to MainWindow"
```
---
## Task 16: Add manual verification checklist to `docs/open.md`
**Files:**
- Modify: `docs/open.md`
- [ ] **Step 1: Append a new section**
Add to the end of `docs/open.md`:
```markdown
## Self-Update — Manual Verification
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with two asset names the code expects — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe` — and `checksums.txt` listing both.
1. Install current version (e.g. `0.2.x`) normally.
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
3. Launch the app — confirm banner appears with "Update available: v0.2.x → v0.3.0".
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date".
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** — confirm the running exe is replaced and the wizard opens on the new version.
7. Repeat step 6 with **Continue anyway** — confirm wizard opens without self-update.
8. Kill network during startup in both the app and the installer — confirm silent fallback (no errors, no banner, wizard opens normally).
```
- [ ] **Step 2: Commit**
```bash
git add docs/open.md
git commit -m "docs(open): add self-update manual verification checklist"
```
---
## Self-Review
Before handing off: walk through `docs/superpowers/specs/2026-04-23-self-update-design.md` one more time and confirm each section maps to a task above.
- Shared module (Spec §Architecture) → Tasks 1, 2, 3
- `VersionComparer` (Spec §Version Comparison) → Tasks 4, 5
- `SelfUpdater` asset matching (Spec §B, step 3) → Task 6
- `SelfUpdater` decision (Spec §B, step 2) → Task 7
- `--replace-self` handler (Spec §B, step 1 + relaunch) → Task 8
- Download + verify (Spec §B, step 4) → Task 9
- Installer entry integration + prompt (Spec §B, step 3) → Task 10
- `UpdateCheckService` (Spec §A) → Task 11
- `InstallerLocator` (Spec §A) → Task 12
- DI wiring (Spec §Architecture Overview) → Task 13
- Shell VM state + commands (Spec §A UI changes + Update action flow) → Task 14
- Banner + Help menu (Spec §A UI changes) → Task 15
- Manual verification (Spec §Testing) → Task 16
No gaps.