# 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`: ```xml net8.0-windows true enable enable false true ``` 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`: ```csharp using System.Net; using System.Net.Http; namespace ClaudeDo.Installer.Tests; internal sealed class FakeHttpMessageHandler : HttpMessageHandler { private readonly Func _handler; public FakeHttpMessageHandler(Func handler) { _handler = handler; } public FakeHttpMessageHandler(HttpStatusCode status, string body) : this(_ => new HttpResponseMessage(status) { Content = new StringContent(body) }) { } public List Requests { get; } = new(); protected override Task 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: ```xml ``` - [ ] **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** ```bash 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`: ```csharp 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`: ```csharp 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(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** ```bash 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`: ```csharp 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()); 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`: ```csharp 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); } /// /// Parses a standard `sha256sum` output: "<hash> <filename>" per line. /// Returns a map keyed by filename. /// public static IReadOnlyDictionary ParseChecksumsFile(string content) { var map = new Dictionary(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** ```bash 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`: ```csharp 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(_ => { }), 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`: ```csharp namespace ClaudeDo.Installer.Core; public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size); public sealed record GiteaRelease( string TagName, string Name, IReadOnlyList Assets); public interface IReleaseClient { Task GetLatestReleaseAsync(CancellationToken ct); Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct); } ``` - [ ] **Step 4: Implement `ReleaseClient`** Create `src/ClaudeDo.Installer/Core/ReleaseClient.cs`: ```csharp 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 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 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(); 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** ```bash 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: ```csharp 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`: ```csharp 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 GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release); public Task DownloadAsync(string url, string destPath, IProgress 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()) }; 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()) }; 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()) }; 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: ```csharp 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`: ```csharp 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 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** ```bash 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`: ```csharp 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 ExecuteAsync(InstallContext ctx, IProgress 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`: ```csharp 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 ExecuteAsync(InstallContext ctx, IProgress 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** ```bash 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`: ```csharp 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 _urlToSourceFile; public GiteaRelease? Release { get; set; } public FileCopyReleaseClient(Dictionary urlToSourceFile) => _urlToSourceFile = urlToSourceFile; public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release); public Task DownloadAsync(string url, string destPath, IProgress 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(_ => { }), 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(_ => { }), 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()); 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(_ => { }), 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(_ => { }), 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`: ```csharp 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 ExecuteAsync(InstallContext ctx, IProgress 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(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")), ct); progress.Report("Downloading checksums..."); await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath, new Progress(_ => { }), 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: ```csharp 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** ```bash 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`: ```csharp 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(_ => { }), 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(_ => { }), 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`: ```csharp using System.IO; using ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Steps; public sealed class WriteInstallManifestStep : IInstallStep { public string Name => "Write Install Manifest"; public Task ExecuteAsync(InstallContext ctx, IProgress 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** ```bash 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** ```bash 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** ```bash 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: ```csharp 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** ```bash 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: ```csharp 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(); context.InstallerVersion = GetInstallerVersion(); // Default install dir for detection — on upgrade we stay where we were. var detector = _services.GetRequiredService(); 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() }, InstallerMode.Config => new SettingsWindow { DataContext = _services.GetRequiredService() }, _ => 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(); return infoAttr?.InformationalVersion ?? "0.0.0"; } private static ServiceProvider BuildServices() { var sc = new ServiceCollection(); // Core sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); // HTTP + release client sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) }); sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService())); sc.AddSingleton(); // Pages sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); // Steps — execution order matters. InstallerService composes per-mode // step lists from this DI set (see WizardViewModel). sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); sc.AddSingleton(); // ViewModels sc.AddSingleton(); sc.AddSingleton(); 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** ```bash 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: ```csharp 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 ``): ```xml