diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0c75e0d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,171 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + env: + DOTNET_ROOT: /home/mika/.dotnet + GITEA_API: https://git.kuns.dev/api/v1 + REPO: releases/ClaudeDo + steps: + - name: Resolve version + id: ver + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building version: $VERSION (tag: $TAG)" + + - name: Prepare workspace + id: ws + run: | + set -euo pipefail + WORK="$(mktemp -d -t claudedo-release-XXXXXX)" + echo "dir=$WORK" >> "$GITHUB_OUTPUT" + echo "Workspace: $WORK" + + - name: Checkout tag + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + WORK: ${{ steps.ws.outputs.dir }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + set -euo pipefail + git clone --depth 1 --branch "$TAG" \ + "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \ + "$WORK/src" + git -C "$WORK/src" log -1 --oneline + + - name: Publish ClaudeDo.App (win-x64, self-contained) + env: + WORK: ${{ steps.ws.outputs.dir }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + export PATH="$DOTNET_ROOT:$PATH" + cd "$WORK/src" + dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \ + -c Release -r win-x64 --self-contained true \ + /p:Version=$VERSION -o out/app + + - name: Publish ClaudeDo.Worker (win-x64, self-contained) + env: + WORK: ${{ steps.ws.outputs.dir }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + export PATH="$DOTNET_ROOT:$PATH" + cd "$WORK/src" + dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \ + -c Release -r win-x64 --self-contained true \ + /p:Version=$VERSION -o out/worker + + - name: Publish ClaudeDo.Installer (win-x64, single-file) + env: + WORK: ${{ steps.ws.outputs.dir }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + export PATH="$DOTNET_ROOT:$PATH" + cd "$WORK/src" + dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \ + -c Release -r win-x64 --self-contained true \ + /p:Version=$VERSION /p:PublishSingleFile=true \ + -o out/installer + + - name: Package assets + env: + WORK: ${{ steps.ws.outputs.dir }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + cd "$WORK/src" + mkdir -p assets + + # 1) App + Worker bundle (top-level dirs /app and /worker) + rm -rf bundle + mkdir -p bundle + cp -r out/app bundle/app + cp -r out/worker bundle/worker + ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip" + ( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker ) + + # 2) Installer single-file exe (renamed) + INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1) + if [ -z "$INSTALLER_EXE" ]; then + echo "::error::No .exe produced by installer publish" >&2 + exit 1 + fi + cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe" + + # 3) Checksums (sha256, relative filenames) + ( cd assets && sha256sum \ + "ClaudeDo-${VERSION}-win-x64.zip" \ + "ClaudeDo.Installer-${VERSION}.exe" \ + > checksums.txt ) + + echo "--- assets ---" + ls -la assets + + - name: Create Gitea Release + id: release + env: + WORK: ${{ steps.ws.outputs.dir }} + TAG: ${{ steps.ver.outputs.tag }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + BODY=$(jq -n \ + --arg tag "$TAG" \ + --arg name "$TAG" \ + '{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}') + RESP=$(curl -sS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "${GITEA_API}/repos/${REPO}/releases") + RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty') + if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then + echo "::error::Release creation failed" >&2 + echo "$RESP" >&2 + exit 1 + fi + echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" + echo "Created release id=$RELEASE_ID for tag=$TAG" + + - name: Upload release assets + env: + WORK: ${{ steps.ws.outputs.dir }} + VERSION: ${{ steps.ver.outputs.version }} + RELEASE_ID: ${{ steps.release.outputs.release_id }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + cd "$WORK/src/assets" + for f in \ + "ClaudeDo-${VERSION}-win-x64.zip" \ + "ClaudeDo.Installer-${VERSION}.exe" \ + "checksums.txt" + do + echo "Uploading: $f" + curl -sS --fail-with-body -X POST \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${f}" \ + "${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \ + > /dev/null + done + echo "All assets uploaded." + + - name: Cleanup workspace + if: always() + env: + WORK: ${{ steps.ws.outputs.dir }} + run: | + rm -rf "$WORK" || true diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx index 785aab8..178690d 100644 --- a/ClaudeDo.slnx +++ b/ClaudeDo.slnx @@ -4,9 +4,11 @@ + + diff --git a/docs/superpowers/plans/2026-04-15-installer-download-mode.md b/docs/superpowers/plans/2026-04-15-installer-download-mode.md new file mode 100644 index 0000000..cb6072f --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-installer-download-mode.md @@ -0,0 +1,2382 @@ +# 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 + + + + + + + + + + + + +