64 KiB
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.csprojsrc/ClaudeDo.Releases/IReleaseClient.cs— moved fromsrc/ClaudeDo.Installer/Core/IReleaseClient.cssrc/ClaudeDo.Releases/ReleaseClient.cs— moved fromsrc/ClaudeDo.Installer/Core/ReleaseClient.cssrc/ClaudeDo.Releases/ChecksumVerifier.cs— moved fromsrc/ClaudeDo.Installer/Core/ChecksumVerifier.cssrc/ClaudeDo.Releases/VersionComparer.cssrc/ClaudeDo.Releases/SelfUpdater.cssrc/ClaudeDo.Releases/SelfUpdateResult.cstests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csprojtests/ClaudeDo.Releases.Tests/VersionComparerTests.cstests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cstests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs— port existing fromtests/ClaudeDo.Installer.Teststests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs— port existing fromtests/ClaudeDo.Installer.Testssrc/ClaudeDo.Ui/Services/UpdateCheckService.cssrc/ClaudeDo.Ui/Services/InstallerLocator.cstests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cstests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
Modified:
ClaudeDo.slnx— addsrc/ClaudeDo.Releases/ClaudeDo.Releases.csprojandtests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj.src/ClaudeDo.Installer/ClaudeDo.Installer.csproj— addProjectReferencetoClaudeDo.Releases.src/ClaudeDo.Installer/Core/InstallModeDetector.cs— useVersionComparerfromClaudeDo.Releases; update namespace imports after the type moves.src/ClaudeDo.Installer/App.xaml.cs— runSelfUpdaterbefore the wizard; handle--replace-selfargument.src/ClaudeDo.Ui/ClaudeDo.Ui.csproj— addProjectReferencetoClaudeDo.Releases.src/ClaudeDo.Ui/Views/MainWindow.axaml— banner above content; Help menu in the titlebar.src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs— wireUpdateCheckService, exposeIsBannerVisible,LatestVersion,InlineStatusText,CheckForUpdatesCommand,UpdateNowCommand,DismissBannerCommand.src/ClaudeDo.App/Program.cs— registerIReleaseClient,HttpClient(viaIHttpClientFactoryor 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:
<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:
<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):
<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
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.csto 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:
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.csto 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.csto 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
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— usesIReleaseClient,GiteaReleasesrc/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs— usesIReleaseClient,ChecksumVerifiersrc/ClaudeDo.Installer/App.xaml.cs— registersReleaseClientin 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
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:
<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:
<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:
- Copy to
tests/ClaudeDo.Releases.Tests/<filename>. - Change namespace from
ClaudeDo.Installer.Tests...toClaudeDo.Releases.Tests. - Update
using ClaudeDo.Installer.Core;→using ClaudeDo.Releases;. - 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
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:
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:
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
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
IsNewermethod with a call toVersionComparer.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:
var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
to:
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
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:
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:
namespace ClaudeDo.Releases;
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
Write src/ClaudeDo.Releases/SelfUpdater.cs:
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
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:
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:
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:
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
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:
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:
/// <summary>
/// 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 <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
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:
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:
/// <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
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:
<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:
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.csOnStartup 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:
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 aSystem.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:
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
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:
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
- Step 2: Write failing tests
Write tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs:
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:
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
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:
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:
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:
<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
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:
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
UpdateCheckServiceandInstallerLocatorintoIslandsShellViewModel
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
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:
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:
_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:
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:
[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
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:
<Grid RowDefinitions="36,*,22">
to:
<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):
<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:
<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
IslandsShellViewModelexposing the service
In src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs, add:
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
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:
## 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
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, 5SelfUpdaterasset matching (Spec §B, step 3) → Task 6SelfUpdaterdecision (Spec §B, step 2) → Task 7--replace-selfhandler (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 11InstallerLocator(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.