17-task TDD plan for rewriting the installer to fetch binaries from releases/ClaudeDo on git.kuns.dev. Covers InstallManifest, ReleaseClient, InstallModeDetector, DownloadAndExtractStep, Config/Repair/Uninstall, and the publish-time single-file self-contained settings. Workflow file is out of scope (handled by VPS Claude). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 KiB
Installer: Download-Mode + Gitea Releases — 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: Rewrite ClaudeDo.Installer so it downloads prebuilt App + Worker binaries from a Gitea release on git.kuns.dev/releases/ClaudeDo instead of building from source, with a three-way launch flow (fresh install / update / config).
Architecture: Fresh install keeps the current wizard (minus the three dotnet publish/deploy steps, plus a new DownloadAndExtractStep). On every subsequent launch, an async InstallModeDetector reads {InstallDir}/install.json, hits the Gitea API, and picks between Update mode (short wizard with download) or Config mode (existing SettingsWindow, now with Repair + Uninstall buttons).
Tech Stack: .NET 8 / WPF + CommunityToolkit.Mvvm (existing), System.Net.Http, System.IO.Compression, System.Security.Cryptography.SHA256, sc.exe via ProcessRunner. xUnit 2.5.3 tests (matching ClaudeDo.Worker.Tests conventions — no mocking library, sealed fakes only).
Branch: feat/installer (already checked out).
Out of scope (handled elsewhere):
.gitea/workflows/release.yml— being written by Claude on the VPS.- Actually publishing releases / runner setup / signing.
- Spec doc — already at
docs/superpowers/specs/2026-04-15-installer-download-mode-design.md.
File Structure Overview
New production files:
| Path | Responsibility |
|---|---|
src/ClaudeDo.Installer/Core/InstallManifest.cs |
install.json record + read/write helpers |
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs |
SHA256 compute + checksum-file parse |
src/ClaudeDo.Installer/Core/IReleaseClient.cs |
Interface for Gitea release queries (DI seam) |
src/ClaudeDo.Installer/Core/ReleaseClient.cs |
Gitea API calls + asset downloads |
src/ClaudeDo.Installer/Core/InstallModeDetector.cs |
Async: reads manifest + queries API, returns DetectedState |
src/ClaudeDo.Installer/Steps/StopServiceStep.cs |
sc stop ClaudeDoWorker with timeout |
src/ClaudeDo.Installer/Steps/StartServiceStep.cs |
sc start ClaudeDoWorker |
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs |
API → download → verify → extract into {InstallDir} |
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs |
Writes install.json at the end of install/update |
Files modified:
| Path | Why |
|---|---|
src/ClaudeDo.Installer/Core/InstallerMode.cs |
Rename enum values, replace ModeDetector with async flow |
src/ClaudeDo.Installer/Core/InstallContext.cs |
Drop SourceDirectory, add InstallerVersion, InstalledVersion, LatestVersion, Mode |
src/ClaudeDo.Installer/App.xaml.cs |
Async mode detection at startup; register new services/steps; pick window per mode |
src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs + .xaml |
Remove source-dir UI; show current/target version for update mode |
src/ClaudeDo.Installer/Views/SettingsViewModel.cs + SettingsWindow.xaml |
Add Repair + Uninstall commands/buttons |
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj |
Publish properties for single-file self-contained |
ClaudeDo.slnx |
Add new test project |
Files deleted:
| Path | Why |
|---|---|
src/ClaudeDo.Installer/Steps/PublishAppStep.cs |
Replaced by DownloadAndExtractStep |
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs |
Same |
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs |
Same |
New test project: tests/ClaudeDo.Installer.Tests/ (xUnit, targets net8.0-windows so it can reference the WPF installer project).
Task 1: Scaffold ClaudeDo.Installer.Tests project
Files:
-
Create:
tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj -
Create:
tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs -
Modify:
ClaudeDo.slnx -
Step 1: Create the test project file
Create tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Installer\ClaudeDo.Installer.csproj" />
</ItemGroup>
</Project>
Note: net8.0-windows + UseWPF=true is required because the installer itself is a WPF project; referencing it from the test project means the test project must use the same TFM flavor.
- Step 2: Add a reusable
FakeHttpMessageHandler
Create tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs:
using System.Net;
using System.Net.Http;
namespace ClaudeDo.Installer.Tests;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
public FakeHttpMessageHandler(HttpStatusCode status, string body)
: this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) })
{
}
public List<HttpRequestMessage> Requests { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(_handler(request));
}
}
- Step 3: Register the test project in
ClaudeDo.slnx
Modify ClaudeDo.slnx by adding one line inside the /tests/ folder:
<Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
</Folder>
- Step 4: Verify it builds (no tests yet, so no test-run)
Run: dotnet build tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
Expected: Build succeeds.
- Step 5: Commit
git add tests/ClaudeDo.Installer.Tests ClaudeDo.slnx
git commit -m "test(installer): scaffold ClaudeDo.Installer.Tests project"
Task 2: InstallManifest + InstallManifestStore
Files:
-
Create:
src/ClaudeDo.Installer/Core/InstallManifest.cs -
Test:
tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs -
Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs:
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallManifestStoreTests : IDisposable
{
private readonly string _tempDir;
public InstallManifestStoreTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoInstallerTests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public void TryRead_ReturnsNull_WhenFileMissing()
{
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
[Fact]
public void Write_Then_Read_RoundTripsAllFields()
{
var manifest = new InstallManifest(
Version: "0.2.0",
InstallDir: _tempDir,
WorkerDir: Path.Combine(_tempDir, "worker"),
InstalledAt: new DateTimeOffset(2026, 4, 15, 12, 34, 56, TimeSpan.Zero));
InstallManifestStore.Write(_tempDir, manifest);
var round = InstallManifestStore.TryRead(_tempDir);
Assert.NotNull(round);
Assert.Equal("0.2.0", round!.Version);
Assert.Equal(manifest.InstallDir, round.InstallDir);
Assert.Equal(manifest.WorkerDir, round.WorkerDir);
Assert.Equal(manifest.InstalledAt, round.InstalledAt);
}
[Fact]
public void Write_CreatesInstallDir_IfMissing()
{
var nested = Path.Combine(_tempDir, "nested");
Assert.False(Directory.Exists(nested));
InstallManifestStore.Write(nested, new InstallManifest(
"0.0.1", nested, Path.Combine(nested, "worker"), DateTimeOffset.UtcNow));
Assert.True(File.Exists(Path.Combine(nested, "install.json")));
}
[Fact]
public void TryRead_ReturnsNull_WhenJsonMalformed()
{
File.WriteAllText(Path.Combine(_tempDir, "install.json"), "{ not json");
var result = InstallManifestStore.TryRead(_tempDir);
Assert.Null(result);
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter InstallManifestStoreTests
Expected: FAIL with "type or namespace name 'InstallManifest' could not be found" (or similar compile error).
- Step 3: Implement
InstallManifest
Create src/ClaudeDo.Installer/Core/InstallManifest.cs:
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClaudeDo.Installer.Core;
public sealed record InstallManifest(
string Version,
string InstallDir,
string WorkerDir,
DateTimeOffset InstalledAt);
public static class InstallManifestStore
{
public const string FileName = "install.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
public static InstallManifest? TryRead(string installDir)
{
var path = ManifestPath(installDir);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallManifest>(json, JsonOptions);
}
catch
{
return null;
}
}
public static void Write(string installDir, InstallManifest manifest)
{
Directory.CreateDirectory(installDir);
var json = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(ManifestPath(installDir), json);
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter InstallManifestStoreTests
Expected: PASS (4 tests).
- Step 5: Commit
git add src/ClaudeDo.Installer/Core/InstallManifest.cs tests/ClaudeDo.Installer.Tests/InstallManifestStoreTests.cs
git commit -m "feat(installer): add InstallManifest + json-backed store"
Task 3: ChecksumVerifier
Files:
-
Create:
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs -
Test:
tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs -
Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs:
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ChecksumVerifierTests : IDisposable
{
private readonly string _tempDir;
public ChecksumVerifierTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
[Fact]
public void ComputeSha256_KnownVector_EmptyFile()
{
// sha256 of empty input = e3b0c442...b855
var path = Path.Combine(_tempDir, "empty.bin");
File.WriteAllBytes(path, Array.Empty<byte>());
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
}
[Fact]
public void ComputeSha256_KnownVector_Hello()
{
// sha256("hello") = 2cf24dba...a9824
var path = Path.Combine(_tempDir, "hello.bin");
File.WriteAllText(path, "hello");
var hash = ChecksumVerifier.ComputeSha256(path);
Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
}
[Fact]
public void Verify_ReturnsTrue_WhenHashMatches()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"));
}
[Fact]
public void Verify_IsCaseInsensitive()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.True(ChecksumVerifier.Verify(path,
"2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824"));
}
[Fact]
public void Verify_ReturnsFalse_OnMismatch()
{
var path = Path.Combine(_tempDir, "x.bin");
File.WriteAllText(path, "hello");
Assert.False(ChecksumVerifier.Verify(path, new string('0', 64)));
}
[Fact]
public void ParseChecksumsFile_ReadsTwoLines()
{
var content = """
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Equal(2, map.Count);
Assert.Equal(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
map["ClaudeDo-0.2.0-win-x64.zip"]);
Assert.Equal(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
map["ClaudeDo.Installer-0.2.0.exe"]);
}
[Fact]
public void ParseChecksumsFile_SkipsBlankAndMalformedLines()
{
var content = """
not a line
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip
""";
var map = ChecksumVerifier.ParseChecksumsFile(content);
Assert.Single(map);
Assert.True(map.ContainsKey("file.zip"));
}
}
- Step 2: Run to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter ChecksumVerifierTests
Expected: FAIL (type not defined).
- Step 3: Implement
ChecksumVerifier
Create src/ClaudeDo.Installer/Core/ChecksumVerifier.cs:
using System.IO;
using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core;
public static class ChecksumVerifier
{
public static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string filePath, string expectedSha256)
{
var actual = ComputeSha256(filePath);
return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parses a standard `sha256sum` output: "<hash> <filename>" per line.
/// Returns a map keyed by filename.
/// </summary>
public static IReadOnlyDictionary<string, string> ParseChecksumsFile(string content)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
map[parts[1].Trim()] = parts[0].Trim();
}
return map;
}
}
- Step 4: Run to verify it passes
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter ChecksumVerifierTests
Expected: PASS (7 tests).
- Step 5: Commit
git add src/ClaudeDo.Installer/Core/ChecksumVerifier.cs tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
git commit -m "feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser)"
Task 4: IReleaseClient + ReleaseClient
Files:
-
Create:
src/ClaudeDo.Installer/Core/IReleaseClient.cs -
Create:
src/ClaudeDo.Installer/Core/ReleaseClient.cs -
Test:
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs -
Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs:
using System.Net;
using System.Net.Http;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class ReleaseClientTests
{
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
[Fact]
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
{
const string json = """
{
"tag_name": "v0.2.0",
"name": "v0.2.0",
"assets": [
{
"name": "ClaudeDo-0.2.0-win-x64.zip",
"browser_download_url": "https://git.example.test/dl/zip",
"size": 12345
},
{
"name": "checksums.txt",
"browser_download_url": "https://git.example.test/dl/checksums",
"size": 128
}
]
}
""";
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.NotNull(release);
Assert.Equal("v0.2.0", release!.TagName);
Assert.Equal(2, release.Assets.Count);
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
Assert.Equal(12345, release.Assets[0].Size);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_On404()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
{
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Null(release);
}
[Fact]
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
Assert.Single(handler.Requests);
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WritesBytesToDisk()
{
var payload = new byte[] { 1, 2, 3, 4, 5 };
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload)
});
using var http = new HttpClient(handler);
var client = new ReleaseClient(http, ApiBase);
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
try
{
await client.DownloadAsync("https://example/foo", tempPath,
new Progress<long>(_ => { }), CancellationToken.None);
Assert.True(File.Exists(tempPath));
Assert.Equal(payload, File.ReadAllBytes(tempPath));
}
finally
{
if (File.Exists(tempPath)) File.Delete(tempPath);
}
}
}
- Step 2: Run to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter ReleaseClientTests
Expected: FAIL (types not defined).
- Step 3: Implement
IReleaseClient
Create src/ClaudeDo.Installer/Core/IReleaseClient.cs:
namespace ClaudeDo.Installer.Core;
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 4: Implement
ReleaseClient
Create src/ClaudeDo.Installer/Core/ReleaseClient.cs:
using System.IO;
using System.Net.Http;
using System.Text.Json;
namespace ClaudeDo.Installer.Core;
public sealed class ReleaseClient : IReleaseClient
{
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
private readonly HttpClient _http;
private readonly string _apiBase;
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
{
_http = http;
_apiBase = apiBase.TrimEnd('/');
}
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
try
{
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(ct);
return ParseRelease(json);
}
catch (HttpRequestException) { return null; }
catch (TaskCanceledException) { return null; }
}
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = File.Create(destPath);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
progress.Report(total);
}
}
private static GiteaRelease? ParseRelease(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
var tag = tagEl.GetString() ?? "";
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
var assets = new List<ReleaseAsset>();
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
var aName = item.GetProperty("name").GetString() ?? "";
var aUrl = item.GetProperty("browser_download_url").GetString() ?? "";
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
}
}
return new GiteaRelease(tag, name, assets);
}
catch (JsonException)
{
return null;
}
}
}
- Step 5: Run to verify tests pass
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter ReleaseClientTests
Expected: PASS (5 tests).
- Step 6: Commit
git add src/ClaudeDo.Installer/Core/IReleaseClient.cs src/ClaudeDo.Installer/Core/ReleaseClient.cs tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
git commit -m "feat(installer): add IReleaseClient + Gitea ReleaseClient"
Task 5: Expand InstallerMode + add InstallModeDetector
Files:
- Modify:
src/ClaudeDo.Installer/Core/InstallerMode.cs - Create:
src/ClaudeDo.Installer/Core/InstallModeDetector.cs - Test:
tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
The existing file has:
public enum InstallerMode { Wizard, Settings }
public static class ModeDetector { public static InstallerMode Detect() { ... } }
We replace both. The old ModeDetector looks at ~/.todo-app/*.json, but that's the worker/ui config, not an install manifest. We now key off {InstallDir}/install.json, which is the authoritative "is ClaudeDo installed" signal.
- Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs:
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public sealed class InstallModeDetectorTests : IDisposable
{
private readonly string _tempDir;
public InstallModeDetectorTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDetector-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FakeReleaseClient : IReleaseClient
{
public GiteaRelease? Release { get; set; }
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
=> throw new NotSupportedException();
}
[Fact]
public async Task Detect_FreshInstall_WhenManifestMissing()
{
var detector = new InstallModeDetector(new FakeReleaseClient());
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.FreshInstall, state.Mode);
Assert.Null(state.Existing);
}
[Fact]
public async Task Detect_Config_WhenManifestPresent_And_Api_Unreachable()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var detector = new InstallModeDetector(new FakeReleaseClient { Release = null });
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
}
[Fact]
public async Task Detect_Update_WhenLatest_GreaterThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.1.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Update, state.Mode);
Assert.Equal("0.1.0", state.Existing!.Version);
Assert.Equal("0.2.0", state.LatestVersion);
}
[Fact]
public async Task Detect_Config_WhenLatest_EqualsOrOlderThan_Installed()
{
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.2.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
[Fact]
public async Task Detect_Config_WhenInstalledIs_Newer_Than_Latest()
{
// Installed from a newer tag than latest release — don't offer downgrade.
InstallManifestStore.Write(_tempDir,
new InstallManifest("0.3.0", _tempDir, _tempDir, DateTimeOffset.UtcNow));
var fake = new FakeReleaseClient
{
Release = new GiteaRelease("v0.2.0", "v0.2.0", Array.Empty<ReleaseAsset>())
};
var detector = new InstallModeDetector(fake);
var state = await detector.DetectAsync(_tempDir, CancellationToken.None);
Assert.Equal(InstallerMode.Config, state.Mode);
}
}
- Step 2: Run to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter InstallModeDetectorTests
Expected: FAIL (types not defined / enum values missing).
- Step 3: Replace
InstallerMode.cscontent
Fully replace src/ClaudeDo.Installer/Core/InstallerMode.cs with:
namespace ClaudeDo.Installer.Core;
public enum InstallerMode
{
FreshInstall, // No install.json present -> run full wizard
Update, // install.json present, newer release available
Config, // install.json present, no update (or API unreachable)
}
The old synchronous ModeDetector.Detect() is removed — replaced by InstallModeDetector below.
- Step 4: Implement
InstallModeDetector
Create src/ClaudeDo.Installer/Core/InstallModeDetector.cs:
namespace ClaudeDo.Installer.Core;
public sealed record DetectedState(
InstallerMode Mode,
InstallManifest? Existing,
GiteaRelease? LatestRelease,
string? LatestVersion);
public sealed class InstallModeDetector
{
private readonly IReleaseClient _releases;
public InstallModeDetector(IReleaseClient releases)
{
_releases = releases;
}
public async Task<DetectedState> DetectAsync(string installDir, CancellationToken ct)
{
var manifest = InstallManifestStore.TryRead(installDir);
if (manifest is null)
return new DetectedState(InstallerMode.FreshInstall, null, null, null);
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version))
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
}
private static bool IsNewer(string latest, string current)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
return lv > cv;
}
}
- Step 5: Run to verify it passes
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter InstallModeDetectorTests
Expected: PASS (5 tests).
- Step 6: Commit
git add src/ClaudeDo.Installer/Core/InstallerMode.cs src/ClaudeDo.Installer/Core/InstallModeDetector.cs tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
git commit -m "feat(installer): replace sync ModeDetector with async InstallModeDetector"
Note: this commit temporarily breaks App.xaml.cs which still references ModeDetector.Detect(). That's fixed in Task 11; it's fine for an intermediate commit because dotnet test on the test project succeeds and the installer project build error is expected until the wiring task. If you want to keep every commit green, stash this commit and squash it into Task 11 — otherwise leave it.
Task 6: StopServiceStep + StartServiceStep
Files:
- Create:
src/ClaudeDo.Installer/Steps/StopServiceStep.cs - Create:
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Both are thin sc.exe wrappers. Manual verification only — tests would need Administrator + a real service, not worth the setup.
- Step 1: Implement
StopServiceStep
Create src/ClaudeDo.Installer/Steps/StopServiceStep.cs:
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}
- Step 2: Implement
StartServiceStep
Create src/ClaudeDo.Installer/Steps/StartServiceStep.cs:
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, output) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok();
// Exit 1056 = already running — that's fine too.
if (output.Contains("1056", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service was already running.");
return StepResult.Ok();
}
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
}
}
- Step 3: Verify the project still builds
Run: dotnet build src/ClaudeDo.Installer
Expected: Build succeeds (steps aren't wired into DI yet — that's Task 11).
- Step 4: Commit
git add src/ClaudeDo.Installer/Steps/StopServiceStep.cs src/ClaudeDo.Installer/Steps/StartServiceStep.cs
git commit -m "feat(installer): add Stop/StartServiceStep sc.exe wrappers"
Task 7: DownloadAndExtractStep
Files:
- Create:
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs - Test:
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
This step is the heart of the new flow. Test strategy: build a real zip on disk, stand up a FakeReleaseClient that "downloads" by copying the prebuilt file, and verify extraction + checksum handling.
- Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs:
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class DownloadAndExtractStepTests : IDisposable
{
private readonly string _tempDir;
private readonly string _installDir;
public DownloadAndExtractStepTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
_installDir = Path.Combine(_tempDir, "install");
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); } catch { }
}
private sealed class FileCopyReleaseClient : IReleaseClient
{
private readonly Dictionary<string, string> _urlToSourceFile;
public GiteaRelease? Release { get; set; }
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
=> _urlToSourceFile = urlToSourceFile;
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
progress.Report(new FileInfo(destPath).Length);
return Task.CompletedTask;
}
}
[Fact]
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
{
// Arrange: build a zip with /app/a.txt and /worker/b.txt
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
var b = zip.CreateEntry("worker/b.txt");
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
}
var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
// Act
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
// Assert
Assert.True(result.Success, result.ErrorMessage);
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
}
[Fact]
public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
{
// Arrange: zip is valid, but checksums.txt says something else.
var zipPath = Path.Combine(_tempDir, "release.zip");
using (var fs = File.Create(zipPath))
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
{
var a = zip.CreateEntry("app/a.txt");
using (var w = new StreamWriter(a.Open())) w.Write("x");
}
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
// Pre-populate install dir — it must NOT be modified on failure.
File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
{
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
new ReleaseAsset("checksums.txt", "fake://checksums", 0),
});
var client = new FileCopyReleaseClient(new()
{
["fake://zip"] = zipPath,
["fake://checksums"] = checksumsPath,
}) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
// Act
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
// Assert
Assert.False(result.Success);
Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
}
[Fact]
public async Task Fails_When_Release_Has_No_Zip_Asset()
{
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
var client = new FileCopyReleaseClient(new()) { Release = release };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Fails_When_ReleaseClient_Returns_Null()
{
var client = new FileCopyReleaseClient(new()) { Release = null };
var step = new DownloadAndExtractStep(client);
var ctx = new InstallContext { InstallDirectory = _installDir };
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}
- Step 2: Run to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter DownloadAndExtractStepTests
Expected: FAIL (type not defined).
- Step 3: Implement
DownloadAndExtractStep
Create src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs:
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep
{
private readonly IReleaseClient _releases;
public DownloadAndExtractStep(IReleaseClient releases)
{
_releases = releases;
}
public string Name => "Download and Extract";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Fetching latest release metadata...");
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
var zipAsset = release.Assets.FirstOrDefault(a =>
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var checksumAsset = release.Assets.FirstOrDefault(a =>
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
if (zipAsset is null)
return StepResult.Fail("Release zip asset not found in release metadata.");
if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(scratchDir);
try
{
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
ct);
progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(File.ReadAllText(checksumPath));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory);
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
// Stash the latest version in the context so WriteInstallManifestStep can persist it.
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}
finally
{
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
}
}
}
- Step 4: Temporarily add
InstalledVersiontoInstallContextso the test compiles
Modify src/ClaudeDo.Installer/Core/InstallContext.cs: add this line anywhere inside the class:
public string? InstalledVersion { get; set; }
(The full InstallContext overhaul is Task 10 — this is a partial change so this task's tests compile.)
- Step 5: Run to verify it passes
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter DownloadAndExtractStepTests
Expected: PASS (4 tests).
- Step 6: Commit
git add src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs src/ClaudeDo.Installer/Core/InstallContext.cs tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
git commit -m "feat(installer): add DownloadAndExtractStep with SHA256 verify"
Task 8: WriteInstallManifestStep
Files:
-
Create:
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs -
Test:
tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs -
Step 1: Write the failing test
Create tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs:
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Tests;
public sealed class WriteInstallManifestStepTests : IDisposable
{
private readonly string _installDir;
public WriteInstallManifestStepTests()
{
_installDir = Path.Combine(Path.GetTempPath(), "ClaudeDoWriteManifest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_installDir);
}
public void Dispose()
{
try { Directory.Delete(_installDir, recursive: true); } catch { }
}
[Fact]
public async Task Writes_Manifest_WithAllFields()
{
var ctx = new InstallContext
{
InstallDirectory = _installDir,
InstalledVersion = "0.2.0",
};
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.True(result.Success);
var manifest = InstallManifestStore.TryRead(_installDir);
Assert.NotNull(manifest);
Assert.Equal("0.2.0", manifest!.Version);
Assert.Equal(_installDir, manifest.InstallDir);
Assert.Equal(Path.Combine(_installDir, "worker"), manifest.WorkerDir);
}
[Fact]
public async Task Fails_When_InstalledVersion_Missing()
{
var ctx = new InstallContext { InstallDirectory = _installDir }; // no version set
var step = new WriteInstallManifestStep();
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
Assert.False(result.Success);
}
}
- Step 2: Run to verify it fails
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter WriteInstallManifestStepTests
Expected: FAIL (type not defined).
- Step 3: Implement the step
Create src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs:
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteInstallManifestStep : IInstallStep
{
public string Name => "Write Install Manifest";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
var manifest = new InstallManifest(
Version: ctx.InstalledVersion!,
InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow);
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
return Task.FromResult(StepResult.Ok());
}
}
- Step 4: Run to verify it passes
Run: dotnet test tests/ClaudeDo.Installer.Tests --filter WriteInstallManifestStepTests
Expected: PASS (2 tests).
- Step 5: Commit
git add src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs tests/ClaudeDo.Installer.Tests/WriteInstallManifestStepTests.cs
git commit -m "feat(installer): add WriteInstallManifestStep"
Task 9: Delete obsolete publish/deploy steps
Files:
-
Delete:
src/ClaudeDo.Installer/Steps/PublishAppStep.cs -
Delete:
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs -
Delete:
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs -
Step 1: Delete the three files
rm src/ClaudeDo.Installer/Steps/PublishAppStep.cs
rm src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
rm src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
The installer project will not build until Task 11 removes the DI registrations, but deleting now keeps the diff small and tidy.
- Step 2: Commit
git add -A src/ClaudeDo.Installer/Steps
git commit -m "refactor(installer): remove source-build steps (replaced by DownloadAndExtractStep)"
Task 10: Update InstallContext
Drop SourceDirectory (no longer needed — no source is required), and add version/mode fields.
Files:
-
Modify:
src/ClaudeDo.Installer/Core/InstallContext.cs -
Step 1: Replace the file
Fully replace src/ClaudeDo.Installer/Core/InstallContext.cs with:
namespace ClaudeDo.Installer.Core;
public sealed class InstallContext
{
// WelcomePage / install destination
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
// Mode + versions (set by App startup after InstallModeDetector runs)
public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
// PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string LogRoot { get; set; } = "~/.todo-app/logs";
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
public string WorktreeRootStrategy { get; set; } = "sibling";
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
// ServicePage
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;
// UiSettingsPage
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
// InstallPage
public bool CreateDesktopShortcut { get; set; } = true;
}
- Step 2: Verify the tests still build
Run: dotnet test tests/ClaudeDo.Installer.Tests --no-restore --list-tests
Expected: Compilation succeeds (installer project still broken — that's Task 11).
- Step 3: Commit
git add src/ClaudeDo.Installer/Core/InstallContext.cs
git commit -m "refactor(installer): replace SourceDirectory with Mode/Version fields in InstallContext"
Task 11: Wire new mode-aware startup + DI in App.xaml.cs
This is the big wiring task. Done right, every later task is a small UI tweak.
Files:
-
Modify:
src/ClaudeDo.Installer/App.xaml.cs -
Step 1: Replace the file
Fully replace src/ClaudeDo.Installer/App.xaml.cs with:
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
using ClaudeDo.Installer.Views;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer;
public partial class App : Application
{
private ServiceProvider? _services;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = BuildServices();
var context = _services.GetRequiredService<InstallContext>();
context.InstallerVersion = GetInstallerVersion();
// Default install dir for detection — on upgrade we stay where we were.
var detector = _services.GetRequiredService<InstallModeDetector>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
DetectedState state;
try
{
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
}
catch (OperationCanceledException)
{
state = new DetectedState(InstallerMode.FreshInstall, null, null, null);
}
context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion;
if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir;
Window mainWindow = state.Mode switch
{
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
{
DataContext = _services.GetRequiredService<WizardViewModel>()
},
InstallerMode.Config => new SettingsWindow
{
DataContext = _services.GetRequiredService<SettingsViewModel>()
},
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
};
DarkTitleBar.Apply(mainWindow);
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose();
base.OnExit(e);
}
private static string GetInstallerVersion()
{
var infoAttr = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
return infoAttr?.InformationalVersion ?? "0.0.0";
}
private static ServiceProvider BuildServices()
{
var sc = new ServiceCollection();
// Core
sc.AddSingleton<InstallContext>();
sc.AddSingleton<PageResolver>();
sc.AddSingleton<InstallerService>();
// HTTP + release client
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters. InstallerService composes per-mode
// step lists from this DI set (see WizardViewModel).
sc.AddSingleton<IInstallStep, DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteInstallManifestStep>();
sc.AddSingleton<StopServiceStep>();
sc.AddSingleton<StartServiceStep>();
// ViewModels
sc.AddSingleton<WizardViewModel>();
sc.AddSingleton<SettingsViewModel>();
return sc.BuildServiceProvider();
}
}
Notes on the DI list:
-
IInstallStepregistrations are the fresh-install order: download → write config → init DB → register service → create shortcuts → write manifest. -
StopServiceStep/StartServiceStepare registered as concrete types (notIInstallStep) so they don't appear in the default fresh-install pipeline — they're pulled out explicitly for the Update and Uninstall flows. -
Step 2: Build
Run: dotnet build src/ClaudeDo.Installer
Expected: Build succeeds.
- Step 3: Run the app and verify it starts
Run: dotnet run --project src/ClaudeDo.Installer
Expected: Installer launches. On a machine without install.json at C:\Program Files\ClaudeDo it shows the fresh-install wizard (currently still referencing SourceDirectory UI — that's broken-by-design until Task 12).
Verification: it doesn't crash on startup. If it does, the typical cause is InstallModeDetector blocking on DI. Fix before proceeding.
- Step 4: Commit
git add src/ClaudeDo.Installer/App.xaml.cs
git commit -m "feat(installer): async mode detection + mode-aware DI wiring"
Task 12: Rewrite WelcomePageViewModel (no source dir, mode-aware)
Files:
-
Modify:
src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs -
Modify:
src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml -
Step 1: Replace the view-model
Fully replace src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs with:
using System.IO;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private WelcomePageView? _view;
public string Title => "Welcome";
public string Icon => "\uE80F";
public int Order => 0;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
[ObservableProperty] private string? _installError;
[ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true;
public WelcomePageViewModel(InstallContext context)
{
_context = context;
}
public Task LoadAsync()
{
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
? @"C:\Program Files\ClaudeDo"
: _context.InstallDirectory;
switch (_context.Mode)
{
case InstallerMode.FreshInstall:
Heading = "Install ClaudeDo";
Subheading = "Choose where to install ClaudeDo, then click Next.";
InstallDirEditable = true;
break;
case InstallerMode.Update:
Heading = $"Update ClaudeDo {_context.InstalledVersion} -> {_context.LatestVersion}";
Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
InstallDirEditable = false; // stay where we were installed
break;
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.InstallDirectory = InstallDirectory;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(InstallDirectory))
{
InstallError = "Install directory is required";
return false;
}
InstallError = null;
return true;
}
[RelayCommand]
private void BrowseInstall()
{
if (!InstallDirEditable) return;
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
if (dialog.ShowDialog() == true)
InstallDirectory = dialog.FolderName;
}
}
- Step 2: Update the XAML
Open src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml. Replace the two source-directory inputs with a single install-directory input, and surface Heading / Subheading as bound labels. The exact layout matches the existing styling — just remove the Source section and keep Install Directory. Here's a minimal replacement for the body (keep the existing namespace/Resources/outer <UserControl>):
<StackPanel Margin="24,16" Orientation="Vertical">
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap" Opacity="0.7" Margin="0,0,0,24"/>
<TextBlock Text="Install Directory" Margin="0,0,0,4"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding InstallDirEditable}"/>
<Button Grid.Column="1"
Content="Browse..."
Margin="8,0,0,0"
Command="{Binding BrowseInstallCommand}"
IsEnabled="{Binding InstallDirEditable}"/>
</Grid>
<TextBlock Text="{Binding InstallError}" Foreground="#F77" Margin="0,4,0,0"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
- Step 3: Build
Run: dotnet build src/ClaudeDo.Installer
Expected: Build succeeds.
- Step 4: Manual smoke-test fresh-install flow
Run: dotnet run --project src/ClaudeDo.Installer
Expected:
- Installer launches into WizardWindow (no
install.jsonexists anywhere yet). - Welcome page shows "Install ClaudeDo" with one install-directory input.
- Clicking Next advances to PathsPage without errors.
Click through: Welcome → Paths → Service → UiSettings → Install. Do NOT click Install (the download step would actually try to hit Gitea — that's Task 17 territory). Just confirm navigation works. Close the window.
- Step 5: Commit
git add src/ClaudeDo.Installer/Pages/WelcomePage
git commit -m "feat(installer): rewrite WelcomePage for download-mode + update heading"
Task 13: Config view — add Repair + Uninstall commands
Files:
- Modify:
src/ClaudeDo.Installer/Views/SettingsViewModel.cs - Modify:
src/ClaudeDo.Installer/Views/SettingsWindow.xaml
Add RepairCommand and UninstallCommand to the existing SettingsViewModel. The actual uninstall logic is in Task 14 — this task just wires the UI and plumbs through InstallerService.
- Step 1: Extend
SettingsViewModel
Open src/ClaudeDo.Installer/Views/SettingsViewModel.cs and replace its contents with:
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
private IInstallerPage? _selectedPage;
[ObservableProperty]
private string? _statusMessage;
[ObservableProperty]
private bool _isStatusError;
[ObservableProperty]
private string _versionLabel = "";
public SettingsViewModel(
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService)
{
Pages = resolver.SettingsPages;
_context = context;
_releases = releases;
_stopService = stopService;
_selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
_ = LoadAllAsync();
}
private async Task LoadAllAsync()
{
foreach (var page in Pages)
await page.LoadAsync();
}
[RelayCommand]
private async Task Save()
{
foreach (var page in Pages)
{
if (!page.Validate())
{
SelectedPage = page;
StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
IsStatusError = true;
return;
}
}
foreach (var page in Pages)
await page.ApplyAsync();
var workerCfg = new InstallerWorkerConfig
{
DbPath = _context.DbPath,
SandboxRoot = _context.SandboxRoot,
LogRoot = _context.LogRoot,
WorktreeRootStrategy = _context.WorktreeRootStrategy,
CentralWorktreeRoot = _context.CentralWorktreeRoot,
QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
SignalRPort = _context.SignalRPort,
ClaudeBin = _context.ClaudeBin,
};
workerCfg.Save();
var uiCfg = new InstallerAppSettings
{
DbPath = _context.UiDbPath,
SignalRUrl = _context.SignalRUrl,
};
uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false;
}
[RelayCommand]
private async Task Repair()
{
var result = MessageBox.Show(
"Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
"Repair ClaudeDo",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (result != MessageBoxResult.OK) return;
StatusMessage = "Repairing... (this window will close when done)";
IsStatusError = false;
// Delegate to the standard Update pipeline: stop service -> download -> start service.
var step1 = _stopService;
var step2 = new DownloadAndExtractStep(_releases);
var step3 = new Steps.StartServiceStep();
var progress = new Progress<string>(msg => StatusMessage = msg);
foreach (var step in new IInstallStep[] { step1, step2, step3 })
{
var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
}
StatusMessage = "Repair complete.";
}
[RelayCommand]
private async Task Uninstall()
{
// Full-removal confirmation. Uninstall logic lives in UninstallRunner (next task).
var result = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
"Uninstall ClaudeDo",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg);
var runner = new UninstallRunner(_context, _stopService);
var r = await runner.RunAsync(progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
MessageBoxButton.OK, MessageBoxImage.Information);
Application.Current.Shutdown();
}
[RelayCommand]
private void Close() => Application.Current.Shutdown();
}
- Step 2: Add Repair + Uninstall buttons to
SettingsWindow.xaml
Open src/ClaudeDo.Installer/Views/SettingsWindow.xaml and add three buttons to the bottom action row, replacing any existing Apply/Cancel pair. The existing Apply rename to Save (bind to SaveCommand). Example for the footer:
<Grid Grid.Row="2" Margin="16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding VersionLabel}" VerticalAlignment="Center" Opacity="0.7"/>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"/>
<Button Grid.Column="4" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
(Keep the existing content area and status message rows; only the footer grid changes.)
- Step 3: Build
Run: dotnet build src/ClaudeDo.Installer
Expected: FAIL — UninstallRunner is referenced but doesn't exist yet. That's Task 14. Keep the error visible so the next commit lands cleanly.
- Step 4: Commit the VM + XAML changes (compile-broken is OK — noted in msg)
git add src/ClaudeDo.Installer/Views
git commit -m "feat(installer): add Repair/Uninstall commands + Save/Close UI to Config view"
Task 14: Uninstall runner (service teardown + filesystem cleanup)
Files:
-
Create:
src/ClaudeDo.Installer/Core/UninstallRunner.cs -
Step 1: Implement the runner
Create src/ClaudeDo.Installer/Core/UninstallRunner.cs:
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Steps;
namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private const string ServiceName = "ClaudeDoWorker";
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
{
_context = context;
_stopService = stopService;
}
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
{
// 1) Stop + delete service.
progress.Report("Stopping worker service...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
{
// Don't bail — service may already be gone. Log and continue.
progress.Report($"(Ignored) {stopResult.ErrorMessage}");
}
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {ServiceName}", null, progress, ct);
// 2) Remove shortcuts.
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"));
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk"));
// 3) Delete install directory.
if (Directory.Exists(_context.InstallDirectory))
{
progress.Report($"Deleting {_context.InstallDirectory}...");
TryDeleteDir(_context.InstallDirectory);
}
// 4) Delete ~/.todo-app (config + DB + logs).
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
{
progress.Report($"Deleting {appData}...");
TryDeleteDir(appData);
}
progress.Report("Uninstall complete.");
return StepResult.Ok();
}
private static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
}
private static void TryDeleteDir(string path)
{
try { Directory.Delete(path, recursive: true); } catch { /* best effort */ }
}
}
Note: Paths.AppDataRoot() comes from ClaudeDo.Data (already referenced in the installer csproj) — same helper the existing ModeDetector used.
- Step 2: Build
Run: dotnet build src/ClaudeDo.Installer
Expected: Build succeeds (Task 13's reference to UninstallRunner now resolves).
- Step 3: Commit
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs
git commit -m "feat(installer): add UninstallRunner (service + shortcuts + dirs)"
Task 15: Teach WizardViewModel about Update mode
The fresh-install wizard runs all pages in order. Update mode should skip Paths/UiSettings/Service (config already exists) and jump Welcome → Install directly.
Files:
-
Modify:
src/ClaudeDo.Installer/Views/WizardViewModel.cs -
Modify:
src/ClaudeDo.Installer/Core/InstallerService.cs -
Step 1: Add a
Mode-aware page filter toWizardViewModel
Replace the Pages initialization in WizardViewModel constructor to filter based on context.Mode. Open src/ClaudeDo.Installer/Views/WizardViewModel.cs and change the constructor + Pages property:
public WizardViewModel(PageResolver resolver, InstallContext context)
{
_context = context;
var all = resolver.WizardPages;
Pages = context.Mode == InstallerMode.Update
? all.Where(p => p is Pages.WelcomePage.WelcomePageViewModel
|| p is Pages.InstallPage.InstallPageViewModel).ToList()
: all;
if (Pages.Count > 0)
_ = InitAsync();
}
- Step 2: Teach
InstallerServiceabout the Update step list
The fresh-install step list already lives in DI registration order. For Update mode we only run: StopServiceStep → DownloadAndExtractStep → StartServiceStep → WriteInstallManifestStep. The simplest change is in InstallPageViewModel — not InstallerService itself — because that's where the step pipeline is kicked off. Check that file's current step invocation: if it iterates IEnumerable<IInstallStep> from DI, it already picks up the list correctly for Fresh-install mode.
For Update mode, InstallPageViewModel should instead pull explicit steps. Since I haven't shown you that file yet, read it first:
Run: cat src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
Expected structure: the VM calls InstallerService.ExecuteAsync(...) once. Adjust it so:
// Inside InstallPageViewModel.ExecuteInstallCommand or similar
var steps = _context.Mode == InstallerMode.Update
? new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
}
: _allSteps; // existing IEnumerable<IInstallStep> from DI
var runner = new InstallerService(steps);
await runner.ExecuteAsync(_context, progress, ct);
To do this you'll need to inject IServiceProvider into InstallPageViewModel (add IServiceProvider to its ctor) and register DownloadAndExtractStep, WriteInstallManifestStep, StopServiceStep, StartServiceStep as both concrete types and IInstallStep (the Singleton lifetime ensures they resolve to the same instance). Adjust App.xaml.cs DI registration:
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// StopServiceStep + StartServiceStep are registered as concrete only (already in Task 11).
Apply the same double-registration pattern to any other IInstallStep you need to reference by concrete type.
- Step 3: Build
Run: dotnet build src/ClaudeDo.Installer
Expected: Build succeeds.
- Step 4: Commit
git add src/ClaudeDo.Installer
git commit -m "feat(installer): mode-aware wizard page list + Update-mode step pipeline"
Task 16: csproj publish properties for single-file self-contained
Files:
-
Modify:
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj -
Step 1: Add publish-time defaults
Add (not replace) a PropertyGroup scoped to publish in src/ClaudeDo.Installer/ClaudeDo.Installer.csproj:
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
These only apply when dotnet publish /p:PublishSingleFile=true is passed (as the workflow does). Debug/Release desktop builds aren't affected.
- Step 2: Build check
Run: dotnet build src/ClaudeDo.Installer
Expected: succeeds.
- Step 3: Publish-test locally (optional but recommended)
Run: dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=0.0.0-test /p:PublishSingleFile=true -o out/installer-test
Verify out/installer-test/ClaudeDo.Installer.exe exists and is standalone (single large exe, no DLLs next to it aside from unavoidable ones like PresentationCore native runtimes — that's expected for WPF).
- Step 4: Commit
git add src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
git commit -m "build(installer): add single-file self-contained publish properties"
Task 17: Full build + manual end-to-end smoke
All code is in. The actual Gitea release workflow may or may not be running yet — doesn't matter for local verification because the installer can be pointed at any Gitea instance.
- Step 1: Full build
Run: dotnet build ClaudeDo.slnx
Expected: All 6 projects build.
- Step 2: Full test run
Run: dotnet test ClaudeDo.slnx
Expected: All tests pass. The Installer.Tests project should show ~20 passing tests.
- Step 3: Manual fresh-install smoke (skip the actual download)
Publish and run the installer in "test" mode. Since there's no real release yet, you can either:
- (a) Skip until the VPS workflow has published v0.0.0-test, then run the real installer, OR
- (b) Stand up a local fake: run a tiny static file server on
localhost:5000serving a handcrafted release zip + checksums, and point the installer at it by overridingReleaseClient.DefaultApiBase(add a CLI arg for this if you want).
Recommended: (a). Coordinate with the VPS-side Claude when their workflow is ready.
- Step 4: Manual Config/Uninstall smoke
Place a fake install.json at C:\Program Files\ClaudeDo\install.json:
{
"version": "9.9.9",
"installDir": "C:\\Program Files\\ClaudeDo",
"workerDir": "C:\\Program Files\\ClaudeDo\\worker",
"installedAt": "2026-04-15T12:00:00Z"
}
Run the installer. Expected: it opens SettingsWindow (Config mode) because 9.9.9 is newer than any real release.
Click Save with no changes → status "Settings saved."
Click Uninstall → confirmation → everything deletes. (Run on a throwaway machine or adjust paths — this really does remove ~/.todo-app.)
- Step 5: Push the branch
git push -u origin feat/installer
Expected: branch pushed to releases/ClaudeDo on git.kuns.dev. Open a PR there (or merge directly on main if you're the only user — your call).
Self-Review Notes
Spec coverage:
- Release artifacts layout → covered by the workflow (not this plan's scope).
install.jsonmarker → Task 2 + 8.- Mode detection (Fresh / Update / Config) → Task 5 + 11 + 15.
DownloadAndExtractStep(API + checksum + extract) → Task 7.- Service stop/start on update → Task 6 + 15.
- Config view Save/Repair/Uninstall → Task 13 + 14.
- Uninstall removes InstallDir +
~/.todo-app→ Task 14. - Old publish/deploy steps deleted → Task 9.
- Self-contained installer published single-file → Task 16.
- Offline = not fatal (Config still opens) → Task 5 test + Task 11 CTS timeout.
- Checksum mismatch = no mutation of InstallDir → Task 7 test.
Placeholder scan: No TBDs, no "add appropriate error handling"-style lines. Every TDD task contains the exact test + implementation.
Type consistency: InstallerMode.FreshInstall / Update / Config used everywhere. InstallContext.InstalledVersion set in Task 7 (step runtime) AND Task 11 (startup). IReleaseClient interface used by detector + download step + settings VM. StopServiceStep / StartServiceStep spelled identically across tasks.
Gap I noticed during review: Task 13's RepairCommand constructs a new DownloadAndExtractStep(_releases) and a new StartServiceStep() inline rather than resolving them from DI. That's fine functionally (they have cheap constructors), but if DownloadAndExtractStep grows dependencies later, switch to DI. Leaving as-is.