Forge
csharpdeeb25a2
1using System.Diagnostics;
2using System.Net.Http.Json;
3using System.Text.Json;
4using CascadeIDE.Services;
5using CascadeIDE.Features.Forge.Infrastructure;
6
7namespace CascadeIDE.Features.Forge.Lens;
8
9/// <summary>Forge Lens connect: OAuth first, device fallback (FORGE-ADR-0011 / 0010).</summary>
10public sealed class ForgeLensDeviceConnectService
11{
12 private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
13 private readonly ForgeLensOAuthConnectService _oauth = new();
14
15 public async Task<(bool Ok, string Message)> ConnectAsync(string baseUrl, CancellationToken ct = default)
16 {
17 var normalized = baseUrl.Trim().TrimEnd('/');
18 if (!Uri.TryCreate(normalized, UriKind.Absolute, out _))
19 return (false, "Некорректный base_url.");
20
21 var (oauthOk, oauthMessage) = await _oauth.TryConnectAsync(normalized, ct).ConfigureAwait(false);
22 if (oauthOk)
23 return (true, oauthMessage);
24
25 if (!oauthMessage.Contains("не настроен", StringComparison.OrdinalIgnoreCase))
26 return (false, oauthMessage);
27
28 using var http = new HttpClient { BaseAddress = new Uri(normalized + "/"), Timeout = TimeSpan.FromMinutes(6) };
29
30 using var beginResponse = await http.PostAsJsonAsync(
31 "/api/v1/auth/device",
32 new { clientName = "cascade-ide", scopes = "read,write,accept_merge" },
33 ct).ConfigureAwait(false);
34 if (!beginResponse.IsSuccessStatusCode)
35 return (false, await ReadErrorAsync(beginResponse, ct).ConfigureAwait(false));
36
37 using var beginDoc = JsonDocument.Parse(await beginResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
38 var root = beginDoc.RootElement;
39 var deviceCode = root.GetProperty("device_code").GetString()!;
40 var userCode = root.GetProperty("user_code").GetString()!;
41 var verificationUri = root.GetProperty("verification_uri").GetString()!;
42 var interval = root.TryGetProperty("interval", out var intervalElement) ? intervalElement.GetInt32() : 5;
43 var expiresIn = root.TryGetProperty("expires_in", out var expiresElement) ? expiresElement.GetInt32() : 900;
44
45 try
46 {
47 Process.Start(new ProcessStartInfo(verificationUri) { UseShellExecute = true });
48 }
49 catch (Exception ex)
50 {
51 return (false, "Не удалось открыть браузер: " + ex.Message);
52 }
53
54 var deadline = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
55 while (DateTimeOffset.UtcNow < deadline)
56 {
57 await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, interval)), ct).ConfigureAwait(false);
58
59 using var pollResponse = await http.PostAsJsonAsync(
60 "/api/v1/auth/device/token",
61 new { deviceCode },
62 ct).ConfigureAwait(false);
63
64 if (pollResponse.IsSuccessStatusCode)
65 {
66 using var pollDoc = JsonDocument.Parse(await pollResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
67 var token = pollDoc.RootElement.GetProperty("access_token").GetString()!;
68 var tokenName = pollDoc.RootElement.TryGetProperty("token_name", out var nameElement)
69 ? nameElement.GetString()
70 : null;
71 ForgeLensSecretsStorage.SetToken(normalized, token, tokenName);
72 await ForgeSlashCatalogRefresh.RefreshAfterConnectAsync(normalized, ct).ConfigureAwait(false);
73 return (true, $"Forge Lens: вход выполнен ({tokenName ?? userCode}).");
74 }
75
76 using var errorDoc = JsonDocument.Parse(await pollResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
77 var error = errorDoc.RootElement.TryGetProperty("error", out var errorElement)
78 ? errorElement.GetString()
79 : null;
80 if (error is not ("authorization_pending" or "slow_down"))
81 return (false, $"Ошибка device login: {error ?? pollResponse.ReasonPhrase}");
82 }
83
84 return (false, "Таймаут device login. Подтверди код на forge или выполни forge auth approve " + userCode + ".");
85 }
86
87 private static async Task<string> ReadErrorAsync(HttpResponseMessage response, CancellationToken ct)
88 {
89 var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
90 try
91 {
92 using var doc = JsonDocument.Parse(body);
93 return doc.RootElement.TryGetProperty("detail", out var detail)
94 ? detail.GetString() ?? response.ReasonPhrase ?? "error"
95 : body;
96 }
97 catch
98 {
99 return string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase ?? "error" : body;
100 }
101 }
102}
103
View only · write via MCP/CIDE