Created
February 2, 2026 22:49
-
-
Save zeljic/87bdb59197bb5f4ab2e096e7eedb5c58 to your computer and use it in GitHub Desktop.
Docker Registry Cleaner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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