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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <owner>` | Org name, or `user` for your personal account |
| `-t, --type <nuget\|npm>` | Package type |
| `-p, --package <name>` | 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)
5 changes: 5 additions & 0 deletions ghpkg.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ghpkg/ghpkg.csproj" />
</Folder>
</Solution>
62 changes: 62 additions & 0 deletions src/ghpkg/GhAuth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Diagnostics;

namespace GhPkg;

/// <summary>Obtains a GitHub token by invoking the `gh` CLI.</summary>
public static class GhAuth
{
public static async Task<string> 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) { }
}
150 changes: 150 additions & 0 deletions src/ghpkg/GitHubPackagesClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;

namespace GhPkg;

public interface IGitHubPackagesClient
{
Task<IReadOnlyList<GitHubOrg>> ListUserOrgsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<GitHubPackage>> ListPackagesAsync(PackageScope scope, string packageType, CancellationToken cancellationToken = default);
Task<IReadOnlyList<GitHubPackageVersion>> ListVersionsAsync(PackageScope scope, string packageType, string packageName, CancellationToken cancellationToken = default);
Task DeleteVersionAsync(PackageScope scope, string packageType, string packageName, long versionId, CancellationToken cancellationToken = default);
}

/// <summary>Identifies whether packages belong to the authenticated user or an organization.</summary>
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<IReadOnlyList<GitHubOrg>> ListUserOrgsAsync(CancellationToken cancellationToken = default) =>
GetPagedAsync(
page => $"user/orgs?per_page={PageSize}&page={page}",
GitHubJsonContext.Default.ListGitHubOrg,
cancellationToken);

public Task<IReadOnlyList<GitHubPackage>> 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<IReadOnlyList<GitHubPackageVersion>> 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<IReadOnlyList<T>> GetPagedAsync<T>(
Func<int, string> urlForPage,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<List<T>> typeInfo,
CancellationToken cancellationToken)
{
List<T> 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;
}
}
Loading
Loading