From 2ffdc53ef719f4b143d4423077c90a8d171007a2 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Fri, 29 Aug 2025 12:23:07 -0300 Subject: [PATCH] Add support for downloading and running Azure DevOps too For now, copied the approach in gist program.cs. --- src/Core/CredentialsExtensions.cs | 2 +- src/Core/DownloadManager.cs | 2 +- src/Core/DownloadProvider.cs | 60 ++++++++++++++++- src/Core/Http/AzureRepoAuthHandler.cs | 32 +++++++-- src/Core/Http/BitbucketAuthHandler.cs | 1 - src/Core/Http/GitHubAuthHandler.cs | 1 - src/Core/Http/RedirectingHttpHandler.cs | 5 +- src/Core/RemoteRef.cs | 29 +++++++- src/Tests/Tests.cs | 27 +++++++- src/gist/Program.cs | 16 ++++- src/runcs/Program.cs | 85 ++++++++++++++++++++++-- src/runcs/Properties/launchSettings.json | 4 ++ 12 files changed, 241 insertions(+), 23 deletions(-) diff --git a/src/Core/CredentialsExtensions.cs b/src/Core/CredentialsExtensions.cs index cc08e90..b8ec477 100644 --- a/src/Core/CredentialsExtensions.cs +++ b/src/Core/CredentialsExtensions.cs @@ -1,7 +1,7 @@ extern alias Devlooped; using GitCredentialManager; -namespace Core; +namespace Devlooped; public static class CredentialsExtensions { diff --git a/src/Core/DownloadManager.cs b/src/Core/DownloadManager.cs index acaf10b..f5790b3 100644 --- a/src/Core/DownloadManager.cs +++ b/src/Core/DownloadManager.cs @@ -6,7 +6,7 @@ public static class DownloadManager { extension(RemoteRef location) { - public string TempPath => Path.Join(GetTempRoot(), location.Owner, location.Repo, location.Ref ?? "main"); + public string TempPath => Path.Join(GetTempRoot(), location.Host ?? "github.com", location.Owner, location.Project ?? "", location.Repo, location.Ref ?? "main"); } /// diff --git a/src/Core/DownloadProvider.cs b/src/Core/DownloadProvider.cs index 1aec503..cf67419 100644 --- a/src/Core/DownloadProvider.cs +++ b/src/Core/DownloadProvider.cs @@ -7,9 +7,10 @@ namespace Devlooped; public abstract class DownloadProvider { - public static DownloadProvider Create(RemoteRef location) => location.Host?.ToLowerInvariant() switch + public static DownloadProvider Create(RemoteRef location) => (location.Host?.ToLowerInvariant() ?? "github.com") switch { "gitlab.com" => new GitLabDownloadProvider(), + "dev.azure.com" => new AzureDevOpsDownloadProvider(), //"bitbucket.org" => new BitbucketDownloadProvider(), _ => new GitHubDownloadProvider(), }; @@ -42,7 +43,7 @@ public override async Task GetAsync(RemoteRef location) var subdomain = gist ? "gist." : ""; var request = new HttpRequestMessage(HttpMethod.Get, // Direct archive link works for branch, tag, sha - new Uri($"https://{subdomain}github.com/{location.Owner}/{location.Repo}/archive/{(location.Ref ?? "main")}.zip")) + new Uri($"https://{subdomain}github.com/{location.Owner}/{location.Repo}/archive/{location.Ref ?? "main"}.zip")) .WithTag(location.ETag); return await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); @@ -79,6 +80,61 @@ public override async Task GetAsync(RemoteRef location) } } +public class AzureDevOpsDownloadProvider : DownloadProvider +{ + static readonly HttpClient http = new(new AzureRepoAuthHandler( + new RedirectingHttpHandler( + new HttpClientHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip, + }, "visualstudio.com"))) + { + Timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(15) + }; + + static AzureDevOpsDownloadProvider() + { + http.DefaultRequestHeaders.TryAddWithoutValidation("X-TFS-FedAuthRedirect", "Suppress"); + } + + public override async Task GetAsync(RemoteRef location) + { + if (location.ResolvedUri != null) + { + return await http.SendAsync( + new HttpRequestMessage(HttpMethod.Get, location.ResolvedUri).WithTag(location.ETag), + HttpCompletionOption.ResponseHeadersRead); + } + + // For Azure DevOps we support dev.azure.com/org/project/repo, defaulting project=repo if not specified + var project = location.Project ?? location.Repo; + + // Branch/ref support + var version = location.Ref ?? "main"; + var url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&$format=zip&api-version=7.1"; + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag); + var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + // try tag & commit + url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=tag&$format=zip&api-version=7.1"; + request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag); + response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=commit&$format=zip&api-version=7.1"; + request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag); + response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + } + } + + return response; + } +} + /* public class BitbucketDownloadProvider : DownloadProvider { diff --git a/src/Core/Http/AzureRepoAuthHandler.cs b/src/Core/Http/AzureRepoAuthHandler.cs index b9a888d..90576df 100644 --- a/src/Core/Http/AzureRepoAuthHandler.cs +++ b/src/Core/Http/AzureRepoAuthHandler.cs @@ -1,4 +1,5 @@ -using System.Text; +extern alias Devlooped; +using System.Text; using GitCredentialManager; using Microsoft.AzureRepos; @@ -18,7 +19,7 @@ protected override async Task SendAsync(HttpRequestMessage return await base.SendAsync(request, cancellationToken); var retry = new HttpRequestMessage(HttpMethod.Get, request.RequestUri); - retry.Headers.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes(creds.Password))); + retry.Headers.Add("Authorization", "Basic " + Convert.ToBase64String(Encoding.ASCII.GetBytes($":{creds.Password}"))); foreach (var etag in request.Headers.IfNoneMatch) { retry.Headers.IfNoneMatch.Add(etag); @@ -27,11 +28,23 @@ protected override async Task SendAsync(HttpRequestMessage return await base.SendAsync(retry, cancellationToken); } - async Task GetCredentialAsync(Uri uri) + async Task GetCredentialAsync(Uri? uri) { - if (credential != null) + if (credential != null || uri == null || + // We need at least the org + uri.PathAndQuery.Split('/', StringSplitOptions.RemoveEmptyEntries) is not { Length: >= 1 } parts) return credential; + // We can use the namespace-less version since we don't need any specific permissions besides + // the built-in GCM GH auth. + var store = Devlooped::GitCredentialManager.CredentialManager.Create(); + + // Try using GCM via API first to retrieve creds + if (parts.Length >= 2 && store.GetCredential($"https://dev.azure.com/{parts[0]}/{parts[1]}") is { } project) + return project; + else if (store.GetCredential($"https://dev.azure.com/{parts[0]}") is { } owner) + return owner; + var input = new InputArguments(new Dictionary { ["protocol"] = "https", @@ -40,7 +53,16 @@ protected override async Task SendAsync(HttpRequestMessage }); var provider = new AzureReposHostProvider(CommandContext.Create()); - credential = await provider.GetCredentialAsync(input); + + try + { + credential = await provider.GetCredentialAsync(input); + store.AddOrUpdate($"https://dev.azure.com/{parts[0]}", credential.Account, credential.Password); + } + catch (Exception) + { + credential = null; + } return credential; } diff --git a/src/Core/Http/BitbucketAuthHandler.cs b/src/Core/Http/BitbucketAuthHandler.cs index 39731cb..0acde0b 100644 --- a/src/Core/Http/BitbucketAuthHandler.cs +++ b/src/Core/Http/BitbucketAuthHandler.cs @@ -1,7 +1,6 @@ extern alias Devlooped; using System.Net; using Atlassian.Bitbucket; -using Core; using GitCredentialManager; using GitCredentialManager.Authentication.OAuth; diff --git a/src/Core/Http/GitHubAuthHandler.cs b/src/Core/Http/GitHubAuthHandler.cs index d1dbfc8..9db65e1 100644 --- a/src/Core/Http/GitHubAuthHandler.cs +++ b/src/Core/Http/GitHubAuthHandler.cs @@ -1,6 +1,5 @@ extern alias Devlooped; using System.Net.Http.Headers; -using Core; using GitCredentialManager; using GitHub; diff --git a/src/Core/Http/RedirectingHttpHandler.cs b/src/Core/Http/RedirectingHttpHandler.cs index a278205..ba50d32 100644 --- a/src/Core/Http/RedirectingHttpHandler.cs +++ b/src/Core/Http/RedirectingHttpHandler.cs @@ -3,7 +3,7 @@ namespace Devlooped.Http; /// A handler that redirects traffic preserving auth/etag headers to originating domain or subdomains. -public sealed class RedirectingHttpHandler(HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler) +public sealed class RedirectingHttpHandler(HttpMessageHandler innerHandler, params string[] followHosts) : DelegatingHandler(innerHandler) { /// Maximum number of redirects to follow. Default is 10. public int MaxRedirects { get; set; } = 10; @@ -29,7 +29,8 @@ protected override async Task SendAsync(HttpRequestMessage var nextHost = NormalizeHost(nextUri.Host); // Never redirect to a different domain (security) - if (!IsWithinSubdomain(originalHost, nextHost)) + if (!IsWithinSubdomain(originalHost, nextHost) && + !followHosts.Any(host => IsWithinSubdomain(host, nextHost))) return response; // Limit to prevent loops diff --git a/src/Core/RemoteRef.cs b/src/Core/RemoteRef.cs index f3ac158..ec8fdae 100644 --- a/src/Core/RemoteRef.cs +++ b/src/Core/RemoteRef.cs @@ -5,7 +5,13 @@ namespace Devlooped; public partial record RemoteRef(string Owner, string Repo, string? Ref, string? Path, string? Host) { - public override string ToString() => $"{(Host == null ? "" : Host + "/")}{Owner}/{Repo}{(Ref == null ? "" : "@" + Ref)}{(Path == null ? "" : ":" + Path)}"; + // When an optional third segment ("project") is provided (owner/repo/project), + // we treat the originally parsed as the Azure DevOps project and the + // third segment as the actual repository name. + public string? Project { get; init; } + + public override string ToString() => + $"{(Host == null ? "" : Host + "/")}{Owner}/{(Project == null ? "" : Project + "/")}{Repo}{(Ref == null ? "" : "@" + Ref)}{(Path == null ? "" : ":" + Path)}"; public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? remote) { @@ -18,13 +24,30 @@ public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? rem var host = match.Groups["host"].Value; var owner = match.Groups["owner"].Value; var repo = match.Groups["repo"].Value; + var project = match.Groups["project"].Value; var reference = match.Groups["ref"].Value; var filePath = match.Groups["path"].Value; + // If a third segment was provided, treat the originally parsed as the Azure DevOps project + if (!string.IsNullOrEmpty(project)) + (project, repo) = (repo, project); + else + project = null; + + // If project is provided, host is required, since GH does not support projects + if (project != null && string.IsNullOrEmpty(host)) + { + remote = null; + return false; + } + remote = new RemoteRef(owner, repo, string.IsNullOrEmpty(reference) ? null : reference, string.IsNullOrEmpty(filePath) ? null : filePath, - string.IsNullOrEmpty(host) ? null : host); + string.IsNullOrEmpty(host) ? null : host) + { + Project = project + }; return true; } @@ -32,6 +55,6 @@ public static bool TryParse(string value, [NotNullWhen(true)] out RemoteRef? rem public string? ETag { get; init; } public Uri? ResolvedUri { get; init; } - [GeneratedRegex(@"^(?:(?[A-Za-z0-9.-]+\.[A-Za-z]{2,})/)?(?[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?[A-Za-z0-9._-]{1,100})(?:@(?[^:\s]+))?(?::(?.+))?$")] + [GeneratedRegex(@"^(?:(?[A-Za-z0-9.-]+\.[A-Za-z]{2,})/)?(?[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38})/(?[A-Za-z0-9._-]{1,100})(?:/(?[A-Za-z0-9._-]{1,100}))?(?:@(?[^:\s]+))?(?::(?.+))?$")] private static partial Regex ParseExp(); } diff --git a/src/Tests/Tests.cs b/src/Tests/Tests.cs index ad47208..9c3aa28 100644 --- a/src/Tests/Tests.cs +++ b/src/Tests/Tests.cs @@ -19,6 +19,8 @@ public void TryParse_OwnerRepoOnly_SetsOwnerRepoAndNullsOthers(string input, str Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + + Assert.NotEmpty(result.TempPath); } [Theory] @@ -44,6 +46,8 @@ public void TryParse_OwnerRepoWithBranch_SetsOwnerRepoAndBranch(string input, st Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + + Assert.NotEmpty(result.TempPath); } [Theory] @@ -66,7 +70,9 @@ public void TryParse_OwnerRepoWithPath_SetsOwnerRepoAndPath(string input, string Assert.Equal(expectedOwner, result.Owner); Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); - Assert.Equal(expectedPath, result.Path); + Assert.Equal(expectedPath, result.Path); Assert.NotEmpty(result.TempPath); + + Assert.NotEmpty(result.TempPath); } [Theory] @@ -88,6 +94,7 @@ public void TryParse_OwnerRepoWithBranchAndPath_SetsAllProperties(string input, Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -141,6 +148,7 @@ public void TryParse_RealWorldExamples_WorksCorrectly(string input, string expec Assert.Equal(expectedRepo, result.Repo); Assert.Null(result.Ref); Assert.Null(result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -160,6 +168,7 @@ public void TryParse_ComplexRealWorldExamples_WorksCorrectly(string input) Assert.NotNull(result.Repo); Assert.NotNull(result.Ref); Assert.NotNull(result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -174,6 +183,7 @@ public void TryParse_EdgeCaseLengths_WorksCorrectly(string input) Assert.NotNull(result); Assert.NotEmpty(result.Owner); Assert.NotEmpty(result.Repo); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -191,6 +201,7 @@ public void TryParse_CaseSensitive_PreservesCase(string input, string expectedOw Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -212,6 +223,7 @@ public void TryParse_SpecialCharacterCombinations_WorksCorrectly(string input, s Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -228,6 +240,7 @@ public void TryParse_EdgeCaseMinimalPaths_WorksCorrectly(string input, string ex Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -237,7 +250,7 @@ public void TryParse_EdgeCaseMinimalPaths_WorksCorrectly(string input, string ex [InlineData("codeberg.org/dev/tool", "codeberg.org", "dev", "tool", null, null)] [InlineData("git.example.com/company/app", "git.example.com", "company", "app", null, null)] [InlineData("source.internal.org/internal/project", "source.internal.org", "internal", "project", null, null)] - [InlineData("dev.azure.com/org/project", "dev.azure.com", "org", "project", null, null)] + [InlineData("dev.azure.com/org/project/repo", "dev.azure.com", "org", "repo", null, null)] [InlineData("git.sr.ht/user/repo", "git.sr.ht", "user", "repo", null, null)] public void TryParse_WithHost_SetsHostAndOwnerRepo(string input, string expectedHost, string expectedOwner, string expectedRepo, string? expectedBranch, string? expectedPath) { @@ -250,6 +263,7 @@ public void TryParse_WithHost_SetsHostAndOwnerRepo(string input, string expected Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -272,6 +286,7 @@ public void TryParse_WithHostAndBranch_SetsHostOwnerRepoAndBranch(string input, Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -294,6 +309,7 @@ public void TryParse_WithHostAndPath_SetsHostOwnerRepoAndPath(string input, stri Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -316,6 +332,7 @@ public void TryParse_WithHostBranchAndPath_SetsAllProperties(string input, strin Assert.Equal(expectedRepo, result.Repo); Assert.Equal(expectedBranch, result.Ref); Assert.Equal(expectedPath, result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -338,6 +355,7 @@ public void TryParse_WithCustomHostDomains_WorksCorrectly(string input, string e Assert.Equal(expectedRepo, result.Repo); Assert.Null(result.Ref); Assert.Null(result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -370,6 +388,7 @@ public void TryParse_WithUppercaseHosts_PreservesCase(string input, string expec Assert.Equal(expectedHost, result.Host); Assert.Equal(expectedOwner, result.Owner); Assert.Equal(expectedRepo, result.Repo); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -390,6 +409,7 @@ public void TryParse_RealWorldHostExamplesWithAllComponents_WorksCorrectly(strin Assert.NotNull(result.Ref); Assert.NotNull(result.Path); Assert.Contains(".", result.Host); // Should have a TLD + Assert.NotEmpty(result.TempPath); } [Fact] @@ -404,6 +424,7 @@ public void TryParse_WithoutHost_HostIsNull() Assert.Equal("repo", result.Repo); Assert.Equal("main", result.Ref); Assert.Equal("file.txt", result.Path); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -421,6 +442,7 @@ public void TryParse_WithEdgeCaseValidHosts_WorksCorrectly(string input, string Assert.Equal(expectedHost, result.Host); Assert.Equal(expectedOwner, result.Owner); Assert.Equal(expectedRepo, result.Repo); + Assert.NotEmpty(result.TempPath); } [Theory] @@ -435,6 +457,7 @@ public void TryParse_WithSpecialCharacters_WorksCorrectly(string input) Assert.NotNull(result.Host); Assert.NotNull(result.Owner); Assert.NotNull(result.Repo); + Assert.NotEmpty(result.TempPath); } [Theory] diff --git a/src/gist/Program.cs b/src/gist/Program.cs index 9f70d22..46251dd 100644 --- a/src/gist/Program.cs +++ b/src/gist/Program.cs @@ -69,9 +69,23 @@ static async Task Main(RemoteRef location, string[] args) var contents = await provider.GetAsync(location); var updated = false; - if (contents.IsSuccessStatusCode && contents.StatusCode != HttpStatusCode.NotModified) + if (!contents.IsSuccessStatusCode) { + AnsiConsole.MarkupLine($":cross_mark: Reference [yellow]{location}[/] not found."); + Dispatcher.MainThread.Shutdown(); + return 1; + } + + if (contents.StatusCode != HttpStatusCode.NotModified) + { +#if DEBUG + await AnsiConsole.Status().StartAsync($":open_file_folder: {location} :backhand_index_pointing_right: {location.TempPath}", async ctx => + { + await contents.ExtractToAsync(location); + }); +#else await contents.ExtractToAsync(location); +#endif if (contents.Headers.ETag?.ToString() is { } newEtag) config.SetString(ThisAssembly.Project.ToolCommandName, $"{location.Owner}/{location.Repo}", location.Ref ?? "main", newEtag); diff --git a/src/runcs/Program.cs b/src/runcs/Program.cs index 680d429..61b28ae 100644 --- a/src/runcs/Program.cs +++ b/src/runcs/Program.cs @@ -1,6 +1,9 @@ -using System.Runtime.InteropServices; +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; using System.Text; using Devlooped; +using DotNetConfig; using GitCredentialManager.UI; using Spectre.Console; @@ -19,7 +22,6 @@ Examples: * kzu/sandbox@v1.0.0:run.cs (implied host github.com, explicit tag and file path) * gitlab.com/kzu/sandbox@main:run.cs (all explicit parts) - * bitbucket.org/kzu/sandbox (implied ref as default branch and path as program.cs) * kzu/sandbox (implied host github.com, ref and path defaults) [bold]args[/] Arguments to pass to the C# program @@ -48,11 +50,86 @@ static async Task Main(RemoteRef location, string[] args) { + var config = Config.Build(Config.GlobalLocation); + var etag = config.GetString(ThisAssembly.Project.ToolCommandName, $"{location.Owner}/{location.Repo}", location.Ref ?? "main"); + if (etag != null && Directory.Exists(location.TempPath)) + { + if (etag.StartsWith("W/\"", StringComparison.OrdinalIgnoreCase) && !etag.EndsWith('"')) + etag += '"'; + + location = location with { ETag = etag }; + } + + if (DotnetMuxer.Path is null) + { + AnsiConsole.MarkupLine($":cross_mark: Unable to locate the .NET SDK."); + Dispatcher.MainThread.Shutdown(); + return 1; + } + var provider = DownloadProvider.Create(location); var contents = await provider.GetAsync(location); + var updated = false; + + if (!contents.IsSuccessStatusCode) + { + AnsiConsole.MarkupLine($":cross_mark: Reference [yellow]{location}[/] not found."); + Dispatcher.MainThread.Shutdown(); + return 1; + } + + if (contents.StatusCode != HttpStatusCode.NotModified) + { +#if DEBUG + await AnsiConsole.Status().StartAsync($":open_file_folder: {location} :backhand_index_pointing_right: {location.TempPath}", async ctx => + { + await contents.ExtractToAsync(location); + }); +#else + await contents.ExtractToAsync(location); +#endif + + if (contents.Headers.ETag?.ToString() is { } newEtag) + config.SetString(ThisAssembly.Project.ToolCommandName, $"{location.Owner}/{location.Repo}", location.Ref ?? "main", newEtag); - AnsiConsole.MarkupLine($":check_box_with_check: {location} :backhand_index_pointing_right: {contents.StatusCode}"); + updated = true; + } + + var program = Path.Combine(location.TempPath, location.Path ?? "program.cs"); + if (!File.Exists(program)) + { + if (location.Path is not null) + { + AnsiConsole.MarkupLine($":cross_mark: File reference not found in gist {location}."); + Dispatcher.MainThread.Shutdown(); + return 1; + } + + var first = Directory.EnumerateFiles(location.TempPath, "*.cs", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (first is null) + { + AnsiConsole.MarkupLine($":cross_mark: No .cs files found in gist {location}."); + Dispatcher.MainThread.Shutdown(); + return 1; + } + program = first; + } + + if (updated) + { + // Clean since it otherwise we get stale build outputs :/ + Process.Start(DotnetMuxer.Path.FullName, ["clean", "-v:q", program]).WaitForExit(); + } + +#if DEBUG + AnsiConsole.MarkupLine($":rocket: {DotnetMuxer.Path.FullName} run -v:q {program} {string.Join(' ', args)}"); +#endif + + var start = new ProcessStartInfo(DotnetMuxer.Path.FullName, ["run", "-v:q", program, .. args]); + var process = Process.Start(start); + process?.WaitForExit(); Dispatcher.MainThread.Shutdown(); - return 0; + + return process?.ExitCode ?? 1; } \ No newline at end of file diff --git a/src/runcs/Properties/launchSettings.json b/src/runcs/Properties/launchSettings.json index d15555f..e84fb79 100644 --- a/src/runcs/Properties/launchSettings.json +++ b/src/runcs/Properties/launchSettings.json @@ -8,6 +8,10 @@ "commandName": "Project", "commandLineArgs": "gitlab.com/kzu/runcs@main:program.cs" }, + "devops": { + "commandName": "Project", + "commandLineArgs": "dev.azure.com/kzu/runcs/runcs@77d857042555c4e60afdb6c41256b2820d6ca14c" + }, "bitbucket": { "commandName": "Project", "commandLineArgs": "bitbucket.org/kzu/runcs@dev:program.cs"