diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2a6515 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Build + run: dotnet build ghpkg.slnx -c Release + + - name: Pack (validation) + run: dotnet pack src/ghpkg -c Release --no-build -o artifacts + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: artifacts/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..64f7172 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Compute version + id: version + run: | + VERSION="$(date -u +%Y).$(date -u +%-m).${{ github.run_number }}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION" + + - name: Build + run: dotnet build ghpkg.slnx -c Release -p:Version=${{ steps.version.outputs.version }} + + - name: Pack + run: dotnet pack src/ghpkg -c Release --no-build -o artifacts -p:Version=${{ steps.version.outputs.version }} + + - name: Push to NuGet.org + run: dotnet nuget push artifacts/*.nupkg --api-key "${{ secrets.NUGET_ORG_APIKEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Tag release + run: | + git tag "v${{ steps.version.outputs.version }}" + git push origin "v${{ steps.version.outputs.version }}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ steps.version.outputs.version }}" artifacts/*.nupkg \ + --title "v${{ steps.version.outputs.version }}" \ + --generate-notes diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fcd52e --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# ghpkg + +Interactive .NET CLI tool to browse and delete GitHub package versions (NuGet and npm) for your user account or an organization. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) (to install the tool) +- [GitHub CLI (`gh`)](https://cli.github.com/), authenticated via `gh auth login` +- Token scopes: `read:packages` (list) and `delete:packages` (delete). Add them with: + + ```sh + gh auth refresh -s read:packages,delete:packages + ``` + +## Installation + +```sh +dotnet tool install --global ghpkg +``` + +Or from a local build: + +```sh +dotnet pack src/ghpkg -c Release +dotnet tool install --global --add-source src/ghpkg/bin/Release ghpkg +``` + +## Usage + +Run with no arguments for a fully interactive experience: + +```sh +ghpkg +``` + +You'll be prompted to choose: + +1. **Scope** — your user account or an organization +2. **Package type** — `nuget` or `npm` +3. **Package** — pick from a list of your packages +4. **Versions** — multi-select versions to delete (space to toggle, enter to accept) +5. **Confirmation** — review the selection before anything is deleted + +### Options + +| Option | Description | +| ------ | ----------- | +| `-o, --owner ` | Org name, or `user` for your personal account | +| `-t, --type ` | Package type | +| `-p, --package ` | Package name to manage | +| `--dry-run` | Preview which versions would be deleted without deleting | + +### Examples + +```sh +# Interactively clean up NuGet packages in an org +ghpkg --owner my-org --type nuget + +# Preview deletion of versions of a specific personal npm package +ghpkg --owner user --type npm --package my-package --dry-run +``` + +## Notes + +- Deleting **all** versions of a package removes the package entirely (the tool warns you). +- Deletion is permanent. GitHub only supports restoring deleted versions within 30 days, and only under certain conditions. + +## License + +[MIT](LICENSE) diff --git a/ghpkg.slnx b/ghpkg.slnx new file mode 100644 index 0000000..7c19863 --- /dev/null +++ b/ghpkg.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ghpkg/GhAuth.cs b/src/ghpkg/GhAuth.cs new file mode 100644 index 0000000..6fb33d8 --- /dev/null +++ b/src/ghpkg/GhAuth.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace GhPkg; + +/// Obtains a GitHub token by invoking the `gh` CLI. +public static class GhAuth +{ + public static async Task GetTokenAsync(CancellationToken cancellationToken = default) + { + ProcessStartInfo startInfo = new() + { + FileName = "gh", + Arguments = "auth token", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + Process process; + try + { + process = Process.Start(startInfo) + ?? throw new GhAuthException("Failed to start the 'gh' process."); + } + catch (Exception ex) when (ex is not GhAuthException) + { + throw new GhAuthException( + "The GitHub CLI ('gh') was not found. Install it from https://cli.github.com/ and run 'gh auth login'.", + ex); + } + + using (process) + { + var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = await process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + throw new GhAuthException( + $"'gh auth token' failed (exit code {process.ExitCode}). " + + $"Run 'gh auth login' to authenticate. Details: {stderr.Trim()}"); + } + + var token = stdout.Trim(); + if (string.IsNullOrEmpty(token)) + { + throw new GhAuthException( + "'gh auth token' returned an empty token. Run 'gh auth login' to authenticate."); + } + + return token; + } + } +} + +public sealed class GhAuthException : Exception +{ + public GhAuthException(string message) : base(message) { } + public GhAuthException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/src/ghpkg/GitHubPackagesClient.cs b/src/ghpkg/GitHubPackagesClient.cs new file mode 100644 index 0000000..422e2ef --- /dev/null +++ b/src/ghpkg/GitHubPackagesClient.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace GhPkg; + +public interface IGitHubPackagesClient +{ + Task> ListUserOrgsAsync(CancellationToken cancellationToken = default); + Task> ListPackagesAsync(PackageScope scope, string packageType, CancellationToken cancellationToken = default); + Task> ListVersionsAsync(PackageScope scope, string packageType, string packageName, CancellationToken cancellationToken = default); + Task DeleteVersionAsync(PackageScope scope, string packageType, string packageName, long versionId, CancellationToken cancellationToken = default); +} + +/// Identifies whether packages belong to the authenticated user or an organization. +public sealed record PackageScope(string? Org) +{ + public bool IsOrg => Org is not null; + + public static PackageScope User { get; } = new((string?)null); + public static PackageScope ForOrg(string org) => new(org); + + public string BasePath => IsOrg ? $"orgs/{Uri.EscapeDataString(Org!)}" : "user"; + public string Display => IsOrg ? $"org '{Org}'" : "your user account"; +} + +public sealed class GitHubPackagesClient : IGitHubPackagesClient, IDisposable +{ + private const int PageSize = 100; + + private readonly HttpClient _httpClient; + + public GitHubPackagesClient(string token) + { + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.github.com/"), + }; + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ghpkg", + typeof(GitHubPackagesClient).Assembly.GetName().Version?.ToString(3) ?? "0.0.0")); + _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + } + + public Task> ListUserOrgsAsync(CancellationToken cancellationToken = default) => + GetPagedAsync( + page => $"user/orgs?per_page={PageSize}&page={page}", + GitHubJsonContext.Default.ListGitHubOrg, + cancellationToken); + + public Task> ListPackagesAsync(PackageScope scope, string packageType, CancellationToken cancellationToken = default) => + GetPagedAsync( + page => $"{scope.BasePath}/packages?package_type={packageType}&per_page={PageSize}&page={page}", + GitHubJsonContext.Default.ListGitHubPackage, + cancellationToken); + + public Task> ListVersionsAsync(PackageScope scope, string packageType, string packageName, CancellationToken cancellationToken = default) => + GetPagedAsync( + page => $"{scope.BasePath}/packages/{packageType}/{Uri.EscapeDataString(packageName)}/versions?per_page={PageSize}&page={page}", + GitHubJsonContext.Default.ListGitHubPackageVersion, + cancellationToken); + + public async Task DeleteVersionAsync(PackageScope scope, string packageType, string packageName, long versionId, CancellationToken cancellationToken = default) + { + var url = $"{scope.BasePath}/packages/{packageType}/{Uri.EscapeDataString(packageName)}/versions/{versionId}"; + using var response = await _httpClient.DeleteAsync(url, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + private async Task> GetPagedAsync( + Func urlForPage, + System.Text.Json.Serialization.Metadata.JsonTypeInfo> typeInfo, + CancellationToken cancellationToken) + { + List results = []; + for (var page = 1; ; page++) + { + using var response = await _httpClient.GetAsync(urlForPage(page), cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var items = await JsonSerializer.DeserializeAsync(stream, typeInfo, cancellationToken) ?? []; + results.AddRange(items); + + if (items.Count < PageSize) + { + return results; + } + } + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var message = TryExtractMessage(body); + + throw response.StatusCode switch + { + HttpStatusCode.NotFound => new GitHubApiException( + "Not found. The package or version may not exist, or your token may lack the 'read:packages' scope.", response.StatusCode, message), + HttpStatusCode.Forbidden when IsRateLimited(response) => new GitHubApiException( + "GitHub API rate limit exceeded. Try again later.", response.StatusCode, message), + HttpStatusCode.Forbidden => new GitHubApiException( + "Permission denied. Listing packages requires the 'read:packages' scope; deleting requires 'delete:packages' and admin permissions on the package. " + + "Run 'gh auth refresh -s read:packages,delete:packages' to update your token scopes.", response.StatusCode, message), + HttpStatusCode.Unauthorized => new GitHubApiException( + "Authentication failed. Run 'gh auth login' to re-authenticate.", response.StatusCode, message), + _ => new GitHubApiException( + $"GitHub API request failed with status {(int)response.StatusCode}.", response.StatusCode, message), + }; + } + + private static bool IsRateLimited(HttpResponseMessage response) => + response.Headers.TryGetValues("X-RateLimit-Remaining", out var values) && + values.FirstOrDefault() == "0"; + + private static string? TryExtractMessage(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("message", out var message) ? message.GetString() : null; + } + catch (JsonException) + { + return null; + } + } + + public void Dispose() => _httpClient.Dispose(); +} + +public sealed class GitHubApiException : Exception +{ + public HttpStatusCode StatusCode { get; } + public string? ApiMessage { get; } + + public GitHubApiException(string message, HttpStatusCode statusCode, string? apiMessage) + : base(apiMessage is null ? message : $"{message} (GitHub: {apiMessage})") + { + StatusCode = statusCode; + ApiMessage = apiMessage; + } +} diff --git a/src/ghpkg/InteractiveFlow.cs b/src/ghpkg/InteractiveFlow.cs new file mode 100644 index 0000000..ec1de36 --- /dev/null +++ b/src/ghpkg/InteractiveFlow.cs @@ -0,0 +1,289 @@ +using Spectre.Console; + +namespace GhPkg; + +/// Interactive flow: pick scope, type, package, versions; confirm; delete. +public sealed class InteractiveFlow(IGitHubPackagesClient client) +{ + public async Task RunAsync( + string? owner, + string? packageType, + string? packageName, + bool dryRun, + CancellationToken cancellationToken = default) + { + var scope = await ResolveScopeAsync(owner, cancellationToken); + packageType ??= AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Which [green]package type[/]?") + .AddChoices("nuget", "npm")); + + // One-shot when a package is named explicitly; otherwise loop back to the package list. + var exitCode = 0; + while (true) + { + var packages = await AnsiConsole.Status().StartAsync( + $"Fetching {packageType} packages for {scope.Display}...", + _ => client.ListPackagesAsync(scope, packageType, cancellationToken)); + + if (packages.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]No {packageType} packages found for {scope.Display}.[/]"); + return exitCode; + } + + var package = SelectPackage(packages, packageName, allowQuit: packageName is null); + if (package is null) + { + return packageName is null ? exitCode : 1; + } + + var result = await ProcessPackageAsync(scope, packageType, package, dryRun, cancellationToken); + exitCode = Math.Max(exitCode, result); + + if (packageName is not null) + { + return exitCode; + } + + AnsiConsole.WriteLine(); + } + } + + private async Task ProcessPackageAsync( + PackageScope scope, + string packageType, + GitHubPackage package, + bool dryRun, + CancellationToken cancellationToken) + { + var versions = await AnsiConsole.Status().StartAsync( + $"Fetching versions of [bold]{Markup.Escape(package.Name)}[/]...", + _ => client.ListVersionsAsync(scope, packageType, package.Name, cancellationToken)); + + if (versions.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No versions found.[/]"); + return 0; + } + + var selected = PromptVersions(package, versions); + if (selected.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]Nothing selected.[/]"); + return 0; + } + + RenderSelectionTable(package, selected); + + if (selected.Count == versions.Count) + { + AnsiConsole.MarkupLine( + "[red]Warning:[/] you selected [bold]all[/] versions. Deleting every version removes the package entirely."); + } + + if (dryRun) + { + AnsiConsole.MarkupLine($"[blue]Dry run:[/] {selected.Count} version(s) would be deleted. No changes made."); + return 0; + } + + if (!AnsiConsole.Confirm($"Delete [red]{selected.Count}[/] version(s) of [bold]{Markup.Escape(package.Name)}[/]?", defaultValue: false)) + { + AnsiConsole.MarkupLine("[yellow]Aborted.[/]"); + return 0; + } + + var failures = await DeleteVersionsAsync(scope, packageType, package.Name, selected, cancellationToken); + + if (failures > 0) + { + AnsiConsole.MarkupLine($"[red]{failures} deletion(s) failed;[/] {selected.Count - failures} succeeded."); + return 1; + } + + AnsiConsole.MarkupLine($"[green]Deleted {selected.Count} version(s) of {Markup.Escape(package.Name)}.[/]"); + return 0; + } + + private async Task ResolveScopeAsync(string? owner, CancellationToken cancellationToken) + { + if (owner is not null) + { + // "user" (or empty) means personal scope; anything else is an org name. + return owner is "" or "user" ? PackageScope.User : PackageScope.ForOrg(owner); + } + + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Whose packages do you want to manage?") + .AddChoices("My user account", "An organization")); + + if (choice == "My user account") + { + return PackageScope.User; + } + + return await SelectOrgAsync(cancellationToken); + } + + private async Task SelectOrgAsync(CancellationToken cancellationToken) + { + IReadOnlyList orgs = []; + try + { + orgs = await AnsiConsole.Status().StartAsync( + "Fetching your organizations...", + _ => client.ListUserOrgsAsync(cancellationToken)); + } + catch (GitHubApiException ex) + { + AnsiConsole.MarkupLine( + $"[yellow]Could not list your organizations ({Markup.Escape(ex.Message)}). " + + "The 'read:org' scope may be missing; falling back to manual entry.[/]"); + } + + if (orgs.Count == 0) + { + var org = AnsiConsole.Prompt(new TextPrompt("Organization name:")); + return PackageScope.ForOrg(org.Trim()); + } + + const string manualEntry = "(enter a name manually)"; + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select an [green]organization[/]:") + .PageSize(15) + .MoreChoicesText("[grey](Move up and down to reveal more organizations)[/]") + .AddChoices(orgs.Select(o => o.Login).OrderBy(o => o, StringComparer.OrdinalIgnoreCase).Append(manualEntry))); + + if (selected == manualEntry) + { + var org = AnsiConsole.Prompt(new TextPrompt("Organization name:")); + return PackageScope.ForOrg(org.Trim()); + } + + return PackageScope.ForOrg(selected); + } + + private static readonly GitHubPackage QuitSentinel = new(-1, "(quit)", "", 0, "", DateTimeOffset.MinValue); + + private static GitHubPackage? SelectPackage(IReadOnlyList packages, string? packageName, bool allowQuit) + { + if (packageName is not null) + { + var match = packages.FirstOrDefault(p => string.Equals(p.Name, packageName, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + AnsiConsole.MarkupLine($"[red]Package '{Markup.Escape(packageName)}' not found.[/] Available packages:"); + foreach (var p in packages) + { + AnsiConsole.MarkupLine($" - {Markup.Escape(p.Name)}"); + } + } + + return match; + } + + var prompt = new SelectionPrompt() + .Title("Select a [green]package[/]:") + .PageSize(15) + .MoreChoicesText("[grey](Move up and down to reveal more packages)[/]") + .UseConverter(p => ReferenceEquals(p, QuitSentinel) + ? "[grey](quit)[/]" + : $"{Markup.Escape(p.Name)} [grey]({p.VersionCount} versions, {p.Visibility})[/]") + .AddChoices(packages.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)); + + if (allowQuit) + { + prompt.AddChoices(QuitSentinel); + } + + var selected = AnsiConsole.Prompt(prompt); + return ReferenceEquals(selected, QuitSentinel) ? null : selected; + } + + private static List PromptVersions(GitHubPackage package, IReadOnlyList versions) + { + var preSelect = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Pre-select which versions?") + .AddChoices("None", "All", "Prerelease only")); + + Func isPreSelected = preSelect switch + { + "All" => _ => true, + "Prerelease only" => IsPrerelease, + _ => _ => false, + }; + + var prompt = new MultiSelectionPrompt() + .Title($"Select versions of [bold]{Markup.Escape(package.Name)}[/] to [red]delete[/]:") + .NotRequired() + .PageSize(20) + .MoreChoicesText("[grey](Move up and down to reveal more versions)[/]") + .InstructionsText("[grey](Press [blue]space[/] to toggle, [green]enter[/] to accept)[/]") + .UseConverter(v => $"{Markup.Escape(v.Name)} [grey](created {v.CreatedAt:yyyy-MM-dd})[/]"); + + foreach (var version in versions.OrderByDescending(v => v.CreatedAt)) + { + var item = prompt.AddChoice(version); + if (isPreSelected(version)) + { + item.Select(); + } + } + + return AnsiConsole.Prompt(prompt); + } + + // SemVer prerelease: a hyphen after the version core (e.g. 1.2.3-beta.1). + private static bool IsPrerelease(GitHubPackageVersion version) => version.Name.Contains('-'); + + private static void RenderSelectionTable(GitHubPackage package, IReadOnlyList selected) + { + var table = new Table() + .Title($"Versions of [bold]{Markup.Escape(package.Name)}[/] to delete") + .AddColumn("Version") + .AddColumn("Created"); + + foreach (var version in selected.OrderByDescending(v => v.CreatedAt)) + { + table.AddRow(Markup.Escape(version.Name), version.CreatedAt.ToString("yyyy-MM-dd HH:mm")); + } + + AnsiConsole.Write(table); + } + + private async Task DeleteVersionsAsync( + PackageScope scope, + string packageType, + string packageName, + IReadOnlyList selected, + CancellationToken cancellationToken) + { + var failures = 0; + await AnsiConsole.Progress() + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new SpinnerColumn()) + .StartAsync(async ctx => + { + var task = ctx.AddTask("Deleting versions", maxValue: selected.Count); + foreach (var version in selected) + { + try + { + await client.DeleteVersionAsync(scope, packageType, packageName, version.Id, cancellationToken); + } + catch (GitHubApiException ex) + { + failures++; + AnsiConsole.MarkupLine($"[red]Failed to delete {Markup.Escape(version.Name)}:[/] {Markup.Escape(ex.Message)}"); + } + + task.Increment(1); + } + }); + + return failures; + } +} diff --git a/src/ghpkg/Models.cs b/src/ghpkg/Models.cs new file mode 100644 index 0000000..be901de --- /dev/null +++ b/src/ghpkg/Models.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace GhPkg; + +public sealed record GitHubPackage( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("package_type")] string PackageType, + [property: JsonPropertyName("version_count")] int VersionCount, + [property: JsonPropertyName("visibility")] string Visibility, + [property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt); + +public sealed record GitHubPackageVersion( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt, + [property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt); + +public sealed record GitHubOrg( + [property: JsonPropertyName("login")] string Login); + +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +public sealed partial class GitHubJsonContext : JsonSerializerContext; diff --git a/src/ghpkg/Program.cs b/src/ghpkg/Program.cs new file mode 100644 index 0000000..cb517af --- /dev/null +++ b/src/ghpkg/Program.cs @@ -0,0 +1,65 @@ +using System.CommandLine; +using GhPkg; +using Spectre.Console; + +Option ownerOption = new("--owner", "-o") +{ + Description = "Owner of the packages: an organization name, or 'user' for your personal account. Prompted if omitted.", +}; + +Option typeOption = new("--type", "-t") +{ + Description = "Package type: nuget or npm. Prompted if omitted.", +}; +typeOption.AcceptOnlyFromAmong("nuget", "npm"); + +Option packageOption = new("--package", "-p") +{ + Description = "Package name to manage. Prompted if omitted.", +}; + +Option dryRunOption = new("--dry-run") +{ + Description = "Preview which versions would be deleted without deleting anything.", +}; + +RootCommand rootCommand = new("Interactively browse and delete GitHub package versions (NuGet and npm).") +{ + ownerOption, + typeOption, + packageOption, + dryRunOption, +}; + +rootCommand.SetAction(async (parseResult, cancellationToken) => +{ + var owner = parseResult.GetValue(ownerOption); + var type = parseResult.GetValue(typeOption); + var package = parseResult.GetValue(packageOption); + var dryRun = parseResult.GetValue(dryRunOption); + + try + { + var token = await GhAuth.GetTokenAsync(cancellationToken); + using var client = new GitHubPackagesClient(token); + var flow = new InteractiveFlow(client); + return await flow.RunAsync(owner, type, package, dryRun, cancellationToken); + } + catch (OperationCanceledException) + { + AnsiConsole.MarkupLine("[yellow]Cancelled.[/]"); + return 130; + } + catch (GhAuthException ex) + { + AnsiConsole.MarkupLine($"[red]Authentication error:[/] {Markup.Escape(ex.Message)}"); + return 1; + } + catch (GitHubApiException ex) + { + AnsiConsole.MarkupLine($"[red]GitHub API error:[/] {Markup.Escape(ex.Message)}"); + return 1; + } +}); + +return await rootCommand.Parse(args).InvokeAsync(); diff --git a/src/ghpkg/ghpkg.csproj b/src/ghpkg/ghpkg.csproj new file mode 100644 index 0000000..adaf121 --- /dev/null +++ b/src/ghpkg/ghpkg.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + GhPkg + enable + enable + true + ghpkg + ghpkg + + 0.0.0-dev + Interactive CLI tool to browse and delete GitHub package versions (NuGet and npm). + MIT + https://github.com/damianh/gh-pgk-cleanup + true + + + + + + + +