Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,29 @@ View [source gist](https://gist.github.com/kzu/52b115ce24c7978ddc33245d4ff840f5)
Run C# code programs from git repos on GitHub, GitLab, Bitbucket and Azure DevOps.

```
Usage: [dnx] runcs REPO_REF [args]
REPO_REF Reference to remote file to run, with format [host/]owner/repo[@ref][:path]
host optional host name (default: github.com)
@ref optional branch, tag, or commit (default: default branch)
:path optional path to file in repo (default: program.cs at repo root)
Usage:
[dnx] runcs <repoRef> [<appArgs>...]

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)
Arguments:
<REPO_REF> Reference to remote file to run, with format [host/]owner/repo[@ref][:path]
host optional host name ([gist.]github.com|gitlab.com|dev.azure.com, default: github.com)
@ref optional branch, tag, or commit (default: default branch)
:path optional path to file in repo (default: program.cs at repo root)

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)
* kzu/sandbox (implied host github.com, ref and path defaults)

<appArgs> Arguments passed to the C# program that is being run.
```

Example:

args Arguments to pass to the C# program
```
dnx kzu/runcs@v1:run.cs dotnet rocks
```


<!-- #runcs -->

Expand Down
1 change: 1 addition & 0 deletions src/Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageReference Include="Devlooped.CredentialManager" Version="42.42.517-main" Aliases="Devlooped" />
<PackageReference Include="git-credential-manager" Version="2.6.1" IncludeAssets="tools" GeneratePathProperty="true" />
<PackageReference Include="ThisAssembly.Project" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
</ItemGroup>

<ItemGroup Condition="'$(Pkggit-credential-manager)' != ''">
Expand Down
25 changes: 25 additions & 0 deletions src/Core/DirectoryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Devlooped;

static class DirectoryExtensions
{
extension(Directory)
{
/// <summary>Creates a temporary user-owned subdirectory for file-based apps.</summary>
public static string CreateUserDirectory(string path)
{
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
}
else
{
// Ensure only the current user has access to the directory to avoid leaking the program to other users.
// We don't mind that permissions might be different if the directory already exists,
// since it's under user's local directory and its path should be unique.
Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}

return path;
}
}
}
50 changes: 0 additions & 50 deletions src/Core/DownloadManager.cs

This file was deleted.

1 change: 1 addition & 0 deletions src/Core/DownloadProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public abstract class DownloadProvider
"gitlab.com" => new GitLabDownloadProvider(),
"dev.azure.com" => new AzureDevOpsDownloadProvider(),
//"bitbucket.org" => new BitbucketDownloadProvider(),
"gist.github.com" => new GitHubDownloadProvider(gist: true),
_ => new GitHubDownloadProvider(),
};

Expand Down
5 changes: 3 additions & 2 deletions src/Core/Http/RedirectingHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
var response = await base.SendAsync(currentRequest, cancellationToken).ConfigureAwait(false);

if (!response.StatusCode.IsRedirect() || response.Headers.Location is null)
{
response.Headers.TryAddWithoutValidation("X-Original-URI", originalUri.AbsoluteUri);
return response;
}

var location = response.Headers.Location;
var nextUri = location.IsAbsoluteUri ? location : new Uri(currentRequest.RequestUri!, location);
Expand Down Expand Up @@ -76,8 +79,6 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
// Copy headers (preserve conditional ones like If-None-Match)
CopyHeaders(currentRequest, next);

next.Headers.TryAddWithoutValidation("X-Original-URI", originalUri.AbsoluteUri);

// We're not going to read this 3xx body; free the socket.
response.Dispose();

Expand Down
4 changes: 2 additions & 2 deletions src/Core/HttpExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static async Task ExtractToAsync(this HttpResponseMessage content, Remote
if (Directory.Exists(location.TempPath))
Directory.Delete(location.TempPath, true);

Directory.CreateDirectory(location.TempPath);
location.EnsureTempPath();

// Extract files while skipping the top-level directory and preserving structure from that point onwards
// This matches the behavior of github/gitlab archive downloads.
Expand Down Expand Up @@ -43,7 +43,7 @@ public static async Task ExtractToAsync(this HttpResponseMessage content, Remote
// Ensure the directory exists
var directoryPath = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(directoryPath))
Directory.CreateDirectory(directoryPath);
Directory.CreateUserDirectory(directoryPath);

entry.ExtractToFile(destinationPath);
}
Expand Down
27 changes: 27 additions & 0 deletions src/Core/RemoteRefExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Runtime.InteropServices;

namespace Devlooped;

public static class RemoteRefExtensions
{
extension(RemoteRef location)
{
public string TempPath => Path.Join(GetTempRoot(), location.Host ?? "github.com", location.Owner, location.Project ?? "", location.Repo, location.Ref ?? "main");

public string EnsureTempPath() => Directory.CreateUserDirectory(location.TempPath);
}

/// <summary>Obtains the temporary directory root, e.g., <c>/tmp/dotnet/runcs/</c>.</summary>
static string GetTempRoot()
{
// We want a location where permissions are expected to be restricted to the current user.
string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.GetTempPath()
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

return Directory.CreateUserDirectory(Path.Join(directory, "dotnet", "runcs"));
}

/// <summary>Obtains a specific temporary path in a subdirectory of the temp root, e.g., <c>/tmp/dotnet/runcs/{name}</c>.</summary>
public static string GetTempSubpath(params string[] name) => Directory.CreateUserDirectory(Path.Join([GetTempRoot(), .. name]));
}
99 changes: 99 additions & 0 deletions src/Core/RemoteRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Net;
using DotNetConfig;
using Spectre.Console;

namespace Devlooped;

public class RemoteRunner(RemoteRef location, string toolName)
{
public async Task<int> RunAsync(string[] args)
{
var config = Config.Build(Config.GlobalLocation);
var etag = config.GetString(toolName, location.ToString(), "etag");
if (etag != null && Directory.Exists(location.TempPath))
{
if (etag.StartsWith("W/\"", StringComparison.OrdinalIgnoreCase) && !etag.EndsWith('"'))
etag += '"';

location = location with { ETag = etag };
}
if (config.TryGetString(toolName, location.ToString(), "uri", out var url) &&
Uri.TryCreate(url, UriKind.Absolute, out var uri))
location = location with { ResolvedUri = uri };

if (DotnetMuxer.Path is null)
{
AnsiConsole.MarkupLine($":cross_mark: Unable to locate the .NET SDK.");
return 1;
}

var provider = DownloadProvider.Create(location);
var contents = await provider.GetAsync(location);
var updated = false;
// We consider a not modified as successful too
var success = contents.IsSuccessStatusCode || contents.StatusCode == HttpStatusCode.NotModified;

if (!success)
{
AnsiConsole.MarkupLine($":cross_mark: Reference [yellow]{location}[/] not found.");
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 = config.SetString(toolName, location.ToString(), "etag", newEtag);

if (contents.Headers.TryGetValues("X-Original-URI", out var urls) && urls.Any())
config = config.SetString(toolName, location.ToString(), "uri", urls.First());
else
config = config.SetString(toolName, location.ToString(), "uri", contents.RequestMessage!.RequestUri!.AbsoluteUri);

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 {location}.");
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 {location}.");
return 1;
}
program = first;
}

if (updated)
{
// Clean since otherwise we sometimes 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();

return process?.ExitCode ?? 1;
}
}
Loading