Files
ClaudeDo/docs/superpowers/plans/2026-04-15-installer-download-mode.md
Mika Kuns c0bd46542a docs(installer): add download-mode implementation plan
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>
2026-04-15 08:37:07 +02:00

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: "&lt;hash&gt;  &lt;filename&gt;" 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.cs content

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 InstalledVersion to InstallContext so 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:

  • IInstallStep registrations are the fresh-install order: download → write config → init DB → register service → create shortcuts → write manifest.

  • StopServiceStep / StartServiceStep are registered as concrete types (not IInstallStep) 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.json exists 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 to WizardViewModel

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 InstallerService about the Update step list

The fresh-install step list already lives in DI registration order. For Update mode we only run: StopServiceStepDownloadAndExtractStepStartServiceStepWriteInstallManifestStep. 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:5000 serving a handcrafted release zip + checksums, and point the installer at it by overriding ReleaseClient.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.json marker → 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.