diff --git a/src/ClaudeDo.Installer/Core/IReleaseClient.cs b/src/ClaudeDo.Installer/Core/IReleaseClient.cs new file mode 100644 index 0000000..8c82449 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/IReleaseClient.cs @@ -0,0 +1,15 @@ +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); +} diff --git a/src/ClaudeDo.Installer/Core/ReleaseClient.cs b/src/ClaudeDo.Installer/Core/ReleaseClient.cs new file mode 100644 index 0000000..4489ea5 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ReleaseClient.cs @@ -0,0 +1,82 @@ +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; + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs b/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs new file mode 100644 index 0000000..954e513 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs @@ -0,0 +1,109 @@ +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); + } + } +}