Skip to content

Instantly share code, notes, and snippets.

@zeljic
Created February 2, 2026 22:49
Show Gist options
  • Select an option

  • Save zeljic/87bdb59197bb5f4ab2e096e7eedb5c58 to your computer and use it in GitHub Desktop.

Select an option

Save zeljic/87bdb59197bb5f4ab2e096e7eedb5c58 to your computer and use it in GitHub Desktop.
Docker Registry Cleaner
#!/usr/bin/env bun
import readline from "node:readline/promises";
const HEADER_ACCEPT_MANIFEST = [
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
].join(", ");
type CatalogResponse = {
repositories?: string[];
};
type TagsResponse = {
name?: string;
tags?: string[] | null;
};
type CLI = readline.Interface & {
stdoutMuted?: boolean;
_writeToOutput?: (stringToWrite: string) => void;
output: typeof process.stdout;
};
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const cli = rl as CLI;
const originalWrite = cli._writeToOutput?.bind(cli);
if (originalWrite) {
cli._writeToOutput = (text: string) => {
if (cli.stdoutMuted) {
cli.output.write("*");
return;
}
originalWrite(text);
};
}
function normalizeBaseUrl(input: string): string {
const trimmed = input.trim().replace(/\/$/, "");
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function buildUrl(baseUrl: string, path: string): string {
return `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
}
function basicAuthHeader(username: string, password: string): string {
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
return `Basic ${token}`;
}
async function promptLine(question: string, fallback = ""): Promise<string> {
const answer = (await rl.question(question)).trim();
return answer === "" ? fallback : answer;
}
async function promptPassword(question: string): Promise<string> {
cli.stdoutMuted = true;
const answer = await rl.question(question);
cli.stdoutMuted = false;
cli.output.write("\n");
return answer.trim();
}
async function fetchJson<T>(
url: string,
init: RequestInit,
): Promise<{ data: T; response: Response }> {
const response = await fetch(url, init);
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP ${response.status} ${response.statusText}: ${body}`);
}
const data = (await response.json()) as T;
return { data, response };
}
async function checkRegistry(baseUrl: string, headers: HeadersInit): Promise<void> {
const url = buildUrl(baseUrl, "/v2/");
const response = await fetch(url, { headers });
if (response.status === 401) {
throw new Error("Unauthorized: check username/password.");
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Registry check failed: HTTP ${response.status} ${response.statusText}: ${body}`);
}
}
function parseNextLink(linkHeader: string | null): string | null {
if (!linkHeader) return null;
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/i);
if (!match) return null;
return match[1];
}
async function listCatalog(
baseUrl: string,
headers: HeadersInit,
): Promise<string[]> {
const repos: string[] = [];
let nextUrl: string | null = buildUrl(baseUrl, "/v2/_catalog?n=1000");
while (nextUrl) {
const { data, response } = await fetchJson<CatalogResponse>(nextUrl, {
headers,
});
if (Array.isArray(data.repositories)) {
repos.push(...data.repositories);
}
nextUrl = parseNextLink(response.headers.get("link"));
}
return repos;
}
async function listTags(
baseUrl: string,
repo: string,
headers: HeadersInit,
): Promise<string[]> {
const url = buildUrl(baseUrl, `/v2/${repo}/tags/list`);
const { data } = await fetchJson<TagsResponse>(url, { headers });
const tags = data.tags ?? [];
return Array.isArray(tags) ? tags : [];
}
async function getManifestDigest(
baseUrl: string,
repo: string,
reference: string,
headers: HeadersInit,
): Promise<string> {
const url = buildUrl(baseUrl, `/v2/${repo}/manifests/${reference}`);
const head = await fetch(url, {
method: "HEAD",
headers: { ...headers, Accept: HEADER_ACCEPT_MANIFEST },
});
if (head.ok) {
const digest = head.headers.get("docker-content-digest");
if (digest) return digest;
}
const getResponse = await fetch(url, {
method: "GET",
headers: { ...headers, Accept: HEADER_ACCEPT_MANIFEST },
});
if (!getResponse.ok) {
const body = await getResponse.text();
throw new Error(`Failed to get manifest for ${repo}:${reference}. ${body}`);
}
const digest = getResponse.headers.get("docker-content-digest");
if (!digest) {
throw new Error(`Manifest digest missing for ${repo}:${reference}.`);
}
return digest;
}
async function deleteManifest(
baseUrl: string,
repo: string,
digest: string,
headers: HeadersInit,
): Promise<void> {
const url = buildUrl(baseUrl, `/v2/${repo}/manifests/${digest}`);
const response = await fetch(url, { method: "DELETE", headers });
if (!response.ok && response.status !== 202) {
const body = await response.text();
throw new Error(`Failed to delete ${digest}. ${body}`);
}
}
async function verifyTagDeleted(
baseUrl: string,
repo: string,
tag: string,
headers: HeadersInit,
): Promise<"deleted" | "exists" | "unauthorized" | "unknown"> {
const url = buildUrl(baseUrl, `/v2/${repo}/manifests/${tag}`);
const response = await fetch(url, {
method: "HEAD",
headers: { ...headers, Accept: HEADER_ACCEPT_MANIFEST },
});
if (response.status === 404) return "deleted";
if (response.ok) return "exists";
if (response.status === 401 || response.status === 403) return "unauthorized";
return "unknown";
}
function resolveRepoName(input: string, repos: string[]): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) {
const idx = Number(trimmed) - 1;
return repos[idx] ?? null;
}
if (repos.includes(trimmed)) return trimmed;
return trimmed;
}
function parseTagSelection(input: string, tags: string[]): string[] {
const trimmed = input.trim();
if (!trimmed || trimmed.toLowerCase() === "all") return tags;
const selections = trimmed
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const resolved: string[] = [];
for (const value of selections) {
if (/^\d+$/.test(value)) {
const idx = Number(value) - 1;
if (tags[idx]) resolved.push(tags[idx]);
continue;
}
if (tags.includes(value)) resolved.push(value);
}
return resolved;
}
async function main(): Promise<void> {
console.log("Docker Registry Cleaner (Bun)");
console.log("--------------------------------");
const urlInput = await promptLine("Registry URL (e.g. https://registry.example.com): ");
const username = await promptLine("Username: ");
const password = await promptPassword("Password: ");
const baseUrl = normalizeBaseUrl(urlInput);
const headers = {
Authorization: basicAuthHeader(username, password),
};
console.log("\nStep 1: Checking registry credentials...");
await checkRegistry(baseUrl, headers);
console.log("OK: registry reachable and credentials accepted.\n");
console.log("Step 2: Fetching repository catalog...");
const repos = await listCatalog(baseUrl, headers);
if (repos.length === 0) {
console.log("No repositories found.");
return;
}
repos.forEach((repo, idx) => {
console.log(`${idx + 1}. ${repo}`);
});
const repoInput = await promptLine("\nRepo name (or number): ");
const repo = resolveRepoName(repoInput, repos);
if (!repo) {
console.log("No repository selected. Exiting.");
return;
}
console.log(`\nStep 3: Listing tags for ${repo}...`);
const tags = await listTags(baseUrl, repo, headers);
if (tags.length === 0) {
console.log("No tags found for this repository.");
return;
}
tags.forEach((tag, idx) => {
console.log(`${idx + 1}. ${tag}`);
});
const tagInput = await promptLine(
"\nTags to delete (comma-separated numbers/names, or 'all') [all]: ",
"all",
);
const selectedTags = parseTagSelection(tagInput, tags);
if (selectedTags.length === 0) {
console.log("No matching tags selected. Exiting.");
return;
}
const verifyInput = await promptLine("Verify deletions after cleanup? [y]: ", "y");
const shouldVerify = ["y", "yes"].includes(verifyInput.trim().toLowerCase());
console.log("\nStep 4: Deleting manifests...");
for (const tag of selectedTags) {
console.log(`- Resolving digest for ${repo}:${tag}`);
const digest = await getManifestDigest(baseUrl, repo, tag, headers);
console.log(` Deleting ${digest}`);
await deleteManifest(baseUrl, repo, digest, headers);
if (shouldVerify) {
const status = await verifyTagDeleted(baseUrl, repo, tag, headers);
if (status === "deleted") {
console.log(` Verified: ${repo}:${tag} is gone (404).`);
} else if (status === "exists") {
console.log(` Warning: ${repo}:${tag} still exists.`);
} else if (status === "unauthorized") {
console.log(` Warning: cannot verify ${repo}:${tag} (unauthorized).`);
} else {
console.log(` Warning: unexpected response while verifying ${repo}:${tag}.`);
}
}
}
console.log("\nDone.\n");
}
try {
await main();
} catch (error) {
console.error("\nError:", error instanceof Error ? error.message : error);
process.exitCode = 1;
} finally {
rl.close();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment