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