commit 661de2f3d8deb52fd941f0a3caa46c46f36470c7 Author: magdev Date: Sat Feb 21 14:37:31 2026 +0100 feat: initial implementation of hubmanager v0.1.0 Add a Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub and self-hosted Docker Registry v2 API with automatic auth detection (bearer token or HTTP basic auth). Subcommands: login, list, tags, inspect, delete, copy, prune Dependencies: curl, jq, bash 4+ Co-Authored-By: Claude Sonnet 4.6 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c1322dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21811ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tests/* +PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e21ae8a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,167 @@ +# hubmanager — AI Session Context + +This file provides context for AI assistants working on this project. + +## Project Summary + +**hubmanager** is a single-file Bash CLI tool to manage Docker Registry images remotely. +It supports both Docker Hub and any self-hosted Docker Registry v2 API, with automatic +authentication detection (bearer token or HTTP basic auth). + +--- + +## What Was Built (Session 2025-02-21) + +### Primary file + +`hubmanager` — executable Bash script, ~600 lines, no dependencies beyond `curl`, `jq`, Bash 4+. + +### Subcommands implemented + +| Command | Description | +| --- | --- | +| `login` | Test credentials against a registry; `--save` writes to `~/.hubmanager.conf` | +| `list` | List repositories (`_catalog` for self-hosted; Hub REST API for Docker Hub) | +| `tags` | List tags for an image with pagination | +| `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support | +| `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` | +| `copy` | Copy/retag within or across registries; blob mount for same-registry retag | +| `prune` | Delete outdated tags sorted by image creation date | + +### Supporting files + +- `PLAN.md` — implementation plan written before coding +- `README.md` — full user-facing documentation with examples +- `tests/fixtures/` — sample JSON responses for testing (token, catalog, tags, manifests, config blob) + +--- + +## Architecture + +### Single-file, section-based layout + +```txt +#!/usr/bin/env bash +set -euo pipefail +# --- Constants --- +# --- Global state --- +# --- Output / Formatting helpers --- +# --- Dependency check --- +# --- Config loading --- +# --- HTTP helpers --- raw_http(), get_response_header() +# --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() +# --- Registry request --- registry_request() ← main HTTP wrapper +# --- Subcommands --- cmd_login/list/tags/inspect/delete/copy/prune +# --- Global arg parsing --- parse_global_args() +# --- Main dispatcher --- main() +main "$@" +``` + +### Key design decisions + +1. **`raw_http METHOD URL [curl-args]`** — thin curl wrapper. Writes headers to a temp file + (`HM_LAST_HEADERS_FILE`), sets `HM_LAST_HTTP_CODE`, outputs body to stdout. + +2. **`registry_request METHOD REGISTRY PATH [options]`** — higher-level wrapper that: + - Probes auth mode via `probe_registry_auth` (result cached per registry) + - Fetches and caches bearer tokens per `"registry|scope"` key + - Retries once on 401 using the challenge scope from `WWW-Authenticate` + - Calls `_handle_http_error` on non-2xx (unless `--no-die`) + +3. **`make_auth_header REGISTRY SCOPE [USER] [PASS]`** — returns the correct + `Authorization:` header string for either bearer or basic auth. + +4. **Authentication flow**: + - `GET /v2/` → parse `WWW-Authenticate` → detect `bearer` or `basic` + - Bearer: `GET ?scope=S&service=S` with `-u user:pass` → cache `.token` + - Basic: construct `Authorization: Basic ` directly + - Tokens cached in `HM_TOKEN_CACHE["registry|scope"]` with epoch expiry + +5. **Docker Hub differences**: + - `list` uses `hub.docker.com/v2/repositories//` (not `_catalog`) + - `delete` and `prune` are blocked (Hub v2 API doesn't support deletion) + - Hub REST API uses a separate JWT from `hub.docker.com/v2/users/login` + +6. **Config file** (`~/.hubmanager.conf`): + - `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`) + - Supports named registry aliases: `REGISTRY__URL/USERNAME/PASSWORD` + - Aliases are resolved in `resolve_registry_alias()` before any operations + +7. **Prune logic**: fetches all tags → filters by `--exclude` pattern → inspects + each tag's config blob for creation date → sorts oldest-first → deletes oldest + (beyond `--keep` count or before `--older-than` days). Supports `--dry-run`. + +8. **Copy blob transfer**: + - Same registry, different repos → attempt cross-repo blob mount (`POST ?mount=`) + - Blob already at destination (`HEAD` returns 200) → skip + - Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest + +--- + +## Global Variables (key ones) + +| Variable | Purpose | +| --- | --- | +| `HM_REGISTRY` | Active registry URL (no trailing slash) | +| `HM_USERNAME` / `HM_PASSWORD` | Active credentials | +| `HM_CONFIG_FILE` | Path to config file | +| `HM_OPT_JSON/VERBOSE/QUIET/NO_COLOR` | Output flags | +| `HM_AUTH_MODE["registry"]` | `bearer`, `basic`, or `none` | +| `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL | +| `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | +| `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | +| `HM_LAST_HTTP_CODE` | HTTP status of most recent request | +| `HM_LAST_HEADERS_FILE` | Temp file path with response headers | +| `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | +| `MANIFEST_ACCEPT_HEADERS` | Array of `-H Accept:` args for all manifest types | + +--- + +## Adding a New Subcommand + +1. Write a `cmd_()` function following the same pattern: + - Parse args with a `while case` loop; handle `--help` + - Set `HM_REGISTRY` default if empty + - Use `registry_request` for API calls + - Check `HM_OPT_JSON` and branch for table vs. JSON output + +2. Add it to the `case` block in `main()`: + + ```bash + newcmd) cmd_newcmd "$@" ;; + ``` + +3. Add it to `show_usage()`. + +4. Run `shellcheck hubmanager` — it must pass with zero warnings. + +--- + +## Exit Codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | General / usage error (`die`) | +| 2 | Authentication failure (`die_auth`) | +| 3 | Not found / 404 (`die_notfound`) | +| 4 | Permission denied / 403 (`die_permission`) | +| 5 | Registry server error (`die_server`) | +| 6 | Network error (`die_network`) | +| 7 | Operation not supported (`die_notsup`) | + +--- + +## Known Limitations / Future Work + +- **Garbage collection** after `delete`/`prune`: the registry only frees disk space + after running `registry garbage-collect`. This tool does not trigger GC; it must + be run separately on the registry host. +- **Docker Hub rate limits**: the `inspect`-per-tag loop in `prune` makes one API + call per tag, which can hit pull rate limits on Docker Hub (prune is blocked on + Hub anyway). +- **Token scope granularity**: the retry-on-401 flow handles most scope mismatches, + but registries that require compound scopes (e.g. `pull,push` together) may need + explicit `--scope` passing inside `registry_request` calls. +- **No `bats` tests yet**: `tests/fixtures/` contains sample API responses. Wire up + `bats-core` tests that mock `curl` with a shell function and feed fixture data. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bcb3a6 --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +# hubmanager + +A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub and any self-hosted Docker Registry v2 API, with flexible authentication. + +## Requirements + +- **Bash** 4.0+ +- **curl** +- **jq** + +## Installation + +```bash +# System-wide +sudo install -m 755 hubmanager /usr/local/bin/hubmanager + +# User-local +install -m 755 hubmanager ~/bin/hubmanager +``` + +## Quick Start + +```bash +# Test your credentials and save them +hubmanager login --registry https://registry.example.com \ + --user admin --password secret --save + +# List all repositories +hubmanager list + +# List tags for an image +hubmanager tags myuser/myapp + +# Inspect an image +hubmanager inspect myuser/myapp:latest + +# Delete an old tag +hubmanager delete myuser/myapp:v1.0.0 + +# Prune old tags (keep the 3 most recent) +hubmanager prune myuser/myapp --keep 3 --dry-run +``` + +## Configuration + +Credentials and registry settings are stored in `~/.hubmanager.conf`. +The file uses a simple `KEY=VALUE` format: + +```bash +# ~/.hubmanager.conf +# chmod 600 ~/.hubmanager.conf + +# Default registry and credentials +REGISTRY=https://registry.example.com +USERNAME=admin +PASSWORD=mysecretpassword + +# Named registry aliases (use with --registry ) +REGISTRY_STAGING_URL=https://staging-registry.example.com +REGISTRY_STAGING_USERNAME=deploy +REGISTRY_STAGING_PASSWORD=deploytoken + +REGISTRY_HUB_URL=https://registry-1.docker.io +REGISTRY_HUB_USERNAME=myuser +REGISTRY_HUB_PASSWORD=myhubtoken +``` + +> The file must be readable only by the owner (`chmod 600`). hubmanager will warn if permissions are too open. + +Named aliases let you switch registries with a short name: + +```bash +hubmanager --registry staging list +hubmanager --registry hub tags myuser/myapp +``` + +## Global Options + +``` +hubmanager [OPTIONS] [COMMAND OPTIONS] + + -r, --registry Registry base URL + Default: https://registry-1.docker.io + -u, --user Username (overrides config file) + -p, --password Password or token (overrides config file) + --config Config file path (default: ~/.hubmanager.conf) + --json Output raw JSON (pipe-friendly) + --no-color Disable ANSI color + -v, --verbose Show HTTP request details (with auth redacted) + -q, --quiet Suppress all non-error output + -h, --help Show help + --version Show version +``` + +--- + +## Commands + +### `login` — Test and save credentials + +``` +hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] +``` + +Validates credentials against the registry. Use `--save` to write them to the config file. + +```bash +hubmanager login --registry https://registry.example.com \ + --user admin --password secret --save +# Login Succeeded — bearer auth, registry: https://registry.example.com +# Credentials saved to /home/user/.hubmanager.conf +``` + +--- + +### `list` — List repositories + +``` +hubmanager list [--limit N] [--last REPO] +``` + +Lists all repositories in the registry. On **Docker Hub**, lists repositories for the authenticated user (Docker Hub restricts the `_catalog` endpoint). + +```bash +hubmanager list +# REPOSITORY +# myuser/myapp +# myuser/myapi +# myuser/nginx-custom + +# Paginate +hubmanager list --limit 50 --last myuser/myapi + +# JSON output +hubmanager list --json | jq '.repositories[]' +``` + +--- + +### `tags` — List tags for an image + +``` +hubmanager tags [--limit N] [--last TAG] +``` + +```bash +hubmanager tags myuser/myapp +# TAG +# latest +# v1.2.3 +# v1.2.2 +# develop + +# Official Docker Hub images +hubmanager tags nginx + +# JSON +hubmanager tags myuser/myapp --json | jq '.tags[]' +``` + +--- + +### `inspect` — Show image details + +``` +hubmanager inspect : [--platform OS/ARCH] +``` + +Shows manifest digest, size, OS/arch, creation date, labels, and layer breakdown. + +```bash +hubmanager inspect myuser/myapp:latest +# Image: myuser/myapp:latest +# Digest: sha256:abc123... +# MediaType: application/vnd.docker.distribution.manifest.v2+json +# CompressedSize: 32.7 MB (34299597 bytes) +# OS/Arch: linux/amd64 +# Created: 2024-01-15T10:30:00.000000000Z +# Labels: +# maintainer=dev@example.com +# version=1.2.3 +# Layers: 3 +# [0] sha256:1111... (27.8 MB) +# [1] sha256:2222... (4.4 MB) +# [2] sha256:3333... (1000.0 KB) + +# Multi-arch image — shows all platforms +hubmanager inspect nginx:latest + +# Multi-arch — drill into a specific platform +hubmanager inspect nginx:latest --platform linux/arm64 + +# Inspect by digest +hubmanager inspect myuser/myapp@sha256:abc123... + +# JSON output (includes _digest field) +hubmanager inspect myuser/myapp:latest --json | jq . +``` + +--- + +### `delete` — Delete a tag or manifest + +``` +hubmanager delete : [--yes] +``` + +Deletes a manifest by resolving the tag to its content-addressable digest, then issuing a `DELETE`. Requires `REGISTRY_STORAGE_DELETE_ENABLED=true` on self-hosted registries. Not supported on Docker Hub. + +```bash +hubmanager delete myuser/myapp:v1.0.0 +# About to delete: myuser/myapp @ sha256:abc123... +# Registry: https://registry.example.com +# Type 'yes' to confirm: yes +# Deleted: myuser/myapp @ sha256:abc123... + +# Skip confirmation +hubmanager delete myuser/myapp:v1.0.0 --yes + +# Delete by digest directly +hubmanager delete myuser/myapp@sha256:abc123... --yes +``` + +--- + +### `copy` — Copy or retag an image + +``` +hubmanager copy : : [options] + +Options: + --src-registry URL Source registry (default: global --registry) + --dst-registry URL Destination registry (default: global --registry) + --src-user USER Source username + --src-password PASS Source password + --dst-user USER Destination username + --dst-password PASS Destination password + --platform OS/ARCH Copy only one platform from a multi-arch image +``` + +**Same-registry retag** — attempts cross-repo blob mount (no data transfer): +```bash +hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable +``` + +**Cross-registry copy** — streams blobs from source to destination: +```bash +hubmanager copy myuser/myapp:latest \ + --src-registry https://registry-1.docker.io \ + --dst-registry https://registry.example.com \ + mycompany/myapp:latest +``` + +**Copy specific platform** from a multi-arch image: +```bash +hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64 +``` + +--- + +### `prune` — Delete outdated tags + +``` +hubmanager prune [options] + +Options: + --keep N Number of recent tags to keep (default: 3) + --older-than DAYS Delete tags older than N days (overrides --keep) + --exclude PATTERN Extended regex of tags to never delete + Default: "^(latest|stable|main|master|release)$" + --no-exclude Disable the default exclusion pattern + -n, --dry-run Show what would be deleted without deleting + -y, --yes Skip confirmation prompt +``` + +Tags are sorted by image creation date (newest first). The newest N are kept; the rest are deleted. Tags matching the exclusion pattern are always preserved. + +```bash +# Preview: keep 5 most recent, protect default tags +hubmanager prune myuser/myapp --keep 5 --dry-run + +# Keep 3 most recent, auto-confirm +hubmanager prune myuser/myapp --keep 3 --yes + +# Delete anything older than 30 days +hubmanager prune myuser/myapp --older-than 30 --dry-run + +# Custom exclusion: never delete latest or any semver tag +hubmanager prune myuser/myapp --keep 5 \ + --exclude "^(latest|v[0-9]+\.[0-9]+(\.[0-9]+)?)$" + +# Prune everything (no exclusions) +hubmanager prune myuser/myapp --keep 1 --no-exclude --dry-run +``` + +--- + +## Authentication + +hubmanager automatically detects the authentication method by probing the registry's `/v2/` endpoint: + +| Registry type | Auth method | +|---|---| +| Docker Hub | Bearer tokens via `auth.docker.io` | +| Harbor, self-hosted with token server | Bearer tokens via registry-configured realm | +| Basic-auth self-hosted | HTTP Basic Auth on every request | +| Public/anonymous registries | No auth | + +Bearer tokens are cached in memory for the duration of the session and refreshed automatically when they expire. + +### Docker Hub notes + +- `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub. +- `delete` is not supported via the v2 API on Docker Hub. Use the web UI at https://hub.docker.com. +- `prune` is not supported on Docker Hub for the same reason. + +--- + +## JSON output + +All commands support `--json` for machine-readable output: + +```bash +# Get all tags as a JSON array +hubmanager tags myapp --json | jq '.tags' + +# Get digest of latest +hubmanager inspect myapp:latest --json | jq '._digest' + +# Delete and capture result +hubmanager delete myapp:old --yes --json +# {"deleted":true,"digest":"sha256:..."} + +# Prune and capture counts +hubmanager prune myapp --keep 3 --yes --json +# {"deleted":5,"failed":0} +``` + +--- + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General / usage error | +| 2 | Authentication failure | +| 3 | Resource not found (404) | +| 4 | Permission denied (403) | +| 5 | Registry server error (5xx) | +| 6 | Network / connectivity error | +| 7 | Operation not supported by registry | + +--- + +## Examples + +```bash +# Mirror all tags of an image to a private registry +for tag in $(hubmanager tags nginx --json | jq -r '.tags[]'); do + hubmanager copy nginx:$tag \ + --src-registry https://registry-1.docker.io \ + --dst-registry https://registry.example.com \ + mycompany/nginx:$tag +done + +# List images older than 60 days (dry run) +hubmanager prune myuser/myapp --older-than 60 --dry-run + +# Get the SHA256 digest of the production image +DIGEST=$(hubmanager inspect myuser/myapp:production --json | jq -r '._digest') +echo "Production image: $DIGEST" + +# Promote staging image to production (retag) +hubmanager copy myuser/myapp:staging myuser/myapp:production +``` + +--- + +## Configuration reference + +| Key | Description | +|-----|-------------| +| `REGISTRY` | Default registry URL | +| `USERNAME` | Default username | +| `PASSWORD` | Default password or token | +| `REGISTRY__URL` | URL for a named registry alias | +| `REGISTRY__USERNAME` | Username for a named alias | +| `REGISTRY__PASSWORD` | Password for a named alias | + +Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`. diff --git a/hubmanager b/hubmanager new file mode 100755 index 0000000..070ea94 --- /dev/null +++ b/hubmanager @@ -0,0 +1,1539 @@ +#!/usr/bin/env bash +# hubmanager - Manage Docker Registry images remotely +# Version: 0.1.0 +# Dependencies: curl, jq, bash 4+ +# Usage: hubmanager --help + +set -euo pipefail + +# ============================================================================= +# --- Constants --- +# ============================================================================= +readonly HM_VERSION="0.1.0" +readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io" +readonly HM_DEFAULT_CONFIG="${HOME}/.hubmanager.conf" +readonly HM_DOCKERHUB_API="https://hub.docker.com" + +# ============================================================================= +# --- Global state --- +# ============================================================================= +HM_REGISTRY="" +HM_USERNAME="" +HM_PASSWORD="" +HM_CONFIG_FILE="$HM_DEFAULT_CONFIG" +HM_OPT_JSON=false +HM_OPT_NO_COLOR=false +HM_OPT_VERBOSE=false +HM_OPT_QUIET=false + +declare -A HM_AUTH_MODE=() # registry -> "bearer"|"basic"|"none" +declare -A HM_AUTH_REALM=() # registry -> realm URL +declare -A HM_AUTH_SERVICE=() # registry -> service name +declare -A HM_TOKEN_CACHE=() # "registry|scope" -> token +declare -A HM_TOKEN_EXPIRY=() # "registry|scope" -> epoch seconds +declare -A HM_CONFIG_VARS=() # raw config key/value pairs + +HM_HUB_TOKEN="" # Docker Hub REST API token (JWT) +HM_TMPFILES=() +HM_LAST_HTTP_CODE="" +HM_LAST_HEADERS_FILE="" + +trap '_hm_cleanup' EXIT INT TERM +_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" 2>/dev/null || true; } + +# ============================================================================= +# --- Output / Formatting helpers --- +# ============================================================================= +C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_BOLD="" C_DIM="" C_RESET="" + +setup_colors() { + if [[ -t 1 && "$HM_OPT_NO_COLOR" == false ]]; then + C_RED=$'\033[0;31m' + C_GREEN=$'\033[0;32m' + C_YELLOW=$'\033[1;33m' + C_CYAN=$'\033[0;36m' + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_RESET=$'\033[0m' + fi +} + +die() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 1; } +die_auth() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 2; } +die_notfound() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 3; } +die_permission() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 4; } +die_server() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 5; } +die_network() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 6; } +die_notsup() { echo "${C_RED}error:${C_RESET} $*" >&2; exit 7; } + +warn() { [[ "$HM_OPT_QUIET" == true ]] || echo "${C_YELLOW}warning:${C_RESET} $*" >&2; } +info() { [[ "$HM_OPT_QUIET" == true ]] || echo "$*"; } +verbose() { [[ "$HM_OPT_VERBOSE" == true ]] || return 0; echo "${C_DIM}$*${C_RESET}" >&2; } + +print_kv() { + printf "${C_BOLD}%-16s${C_RESET} %s\n" "$1:" "$2" +} + +human_size() { + local bytes="$1" + awk -v b="$bytes" 'BEGIN { + if (b >= 1073741824) printf "%.1f GB", b/1073741824 + else if (b >= 1048576) printf "%.1f MB", b/1048576 + else if (b >= 1024) printf "%.1f KB", b/1024 + else printf "%d B", b + }' +} + +# ============================================================================= +# --- Dependency check --- +# ============================================================================= +check_deps() { + local missing=() + for cmd in curl jq; do + command -v "$cmd" &>/dev/null || missing+=("$cmd") + done + if (( ${#missing[@]} > 0 )); then + die "Missing required dependencies: ${missing[*]}. Please install them and retry." + fi + (( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)." +} + +# ============================================================================= +# --- Config loading --- +# ============================================================================= +load_config() { + local file="$HM_CONFIG_FILE" + [[ -f "$file" ]] || return 0 + + # Warn if file is world-readable + local perms + perms=$(stat -c %a "$file" 2>/dev/null || stat -f %Lp "$file" 2>/dev/null || echo "") + if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then + warn "Config file '$file' has permissions $perms. Recommended: chmod 600 $file" + fi + + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line//[[:space:]]/}" ]] && continue + + # Split on first = + key="${line%%=*}" + value="${line#*=}" + + # Trim whitespace from key + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + [[ -z "$key" ]] && continue + + # Strip inline comments and trim whitespace from value + value="${value%%#*}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + HM_CONFIG_VARS["$key"]="$value" + done < "$file" + + # Apply config defaults (CLI flags already set take precedence) + [[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}" + [[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}" + [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]] && HM_PASSWORD="${HM_CONFIG_VARS[PASSWORD]}" +} + +resolve_registry_alias() { + # If REGISTRY looks like a short alias (no dots, slashes, or scheme), expand it + local reg="$HM_REGISTRY" + if [[ -n "$reg" && "$reg" != *"."* && "$reg" != *"/"* && "$reg" != http* ]]; then + local alias_upper="${reg^^}" + alias_upper="${alias_upper//-/_}" + local url_key="REGISTRY_${alias_upper}_URL" + if [[ -v "HM_CONFIG_VARS[$url_key]" ]]; then + local user_key="REGISTRY_${alias_upper}_USERNAME" + local pass_key="REGISTRY_${alias_upper}_PASSWORD" + HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}" + [[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}" + [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]] && HM_PASSWORD="${HM_CONFIG_VARS[$pass_key]}" + fi + fi + # Strip trailing slash + HM_REGISTRY="${HM_REGISTRY%/}" +} + +is_dockerhub() { + local registry="${1:-$HM_REGISTRY}" + [[ "$registry" == *"registry-1.docker.io"* || "$registry" == *"docker.io"* ]] +} + +# ============================================================================= +# --- HTTP helpers --- +# ============================================================================= + +# raw_http METHOD URL [curl-options...] +# Sets: HM_LAST_HTTP_CODE, HM_LAST_HEADERS_FILE +# Outputs: response body to stdout (empty for HEAD) +raw_http() { + local method="$1" + local url="$2" + shift 2 + + local body_file header_file + body_file=$(mktemp); HM_TMPFILES+=("$body_file") + header_file=$(mktemp); HM_TMPFILES+=("$header_file") + HM_LAST_HEADERS_FILE="$header_file" + + local -a method_flags=() + if [[ "$method" == "HEAD" ]]; then + method_flags=("--head") + else + method_flags=("-X" "$method") + fi + + verbose ">>> $method $url" + + HM_LAST_HTTP_CODE=$(curl -s \ + -w "%{http_code}" \ + "${method_flags[@]}" \ + -D "$header_file" \ + -o "$body_file" \ + "$@" \ + "$url" \ + 2>/dev/null) || HM_LAST_HTTP_CODE="000" + + verbose "<<< HTTP $HM_LAST_HTTP_CODE" + + [[ "$method" != "HEAD" ]] && cat "$body_file" + return 0 +} + +get_response_header() { + local name="$1" + grep -i "^${name}:" "$HM_LAST_HEADERS_FILE" 2>/dev/null | tr -d '\r' | head -1 | sed 's/^[^:]*: *//' || true +} + +# ============================================================================= +# --- Authentication --- +# ============================================================================= + +probe_registry_auth() { + local registry="$1" + [[ -v "HM_AUTH_MODE[$registry]" ]] && return 0 + + local header_file + header_file=$(mktemp); HM_TMPFILES+=("$header_file") + + local http_code + http_code=$(curl -s -o /dev/null -D "$header_file" -w "%{http_code}" \ + "${registry}/v2/" 2>/dev/null) || http_code="000" + + if [[ "$http_code" == "000" ]]; then + die_network "Cannot connect to registry at $registry. Check the URL and your network." + fi + + local www_auth + www_auth=$(grep -i "^www-authenticate:" "$header_file" | tr -d '\r' | head -1 || true) + verbose "Auth probe for $registry: HTTP $http_code, WWW-Authenticate: $www_auth" + + if echo "$www_auth" | grep -qi "bearer"; then + HM_AUTH_MODE["$registry"]="bearer" + HM_AUTH_REALM["$registry"]=$(echo "$www_auth" | grep -oi 'realm="[^"]*"' | cut -d'"' -f2 || true) + HM_AUTH_SERVICE["$registry"]=$(echo "$www_auth" | grep -oi 'service="[^"]*"' | cut -d'"' -f2 || true) + elif echo "$www_auth" | grep -qi "basic"; then + HM_AUTH_MODE["$registry"]="basic" + else + HM_AUTH_MODE["$registry"]="none" + fi + + verbose "Auth mode for $registry: ${HM_AUTH_MODE[$registry]}" +} + +_fetch_bearer_token() { + local registry="$1" + local scope="$2" + local username="${3:-$HM_USERNAME}" + local password="${4:-$HM_PASSWORD}" + + local realm="${HM_AUTH_REALM[$registry]:-}" + local service="${HM_AUTH_SERVICE[$registry]:-}" + + [[ -n "$realm" ]] || die_auth "No bearer realm configured for $registry. Try probing auth first." + + local token_url="${realm}?scope=${scope}" + [[ -n "$service" ]] && token_url+="&service=${service}" + verbose "Fetching token from: $token_url" + + local response + if [[ -n "$username" && -n "$password" ]]; then + response=$(curl -s -u "${username}:${password}" "$token_url" 2>/dev/null) || \ + die_network "Cannot reach auth server: $realm" + else + response=$(curl -s "$token_url" 2>/dev/null) || \ + die_network "Cannot reach auth server: $realm" + fi + + local token + token=$(echo "$response" | jq -r '.token // .access_token // empty' 2>/dev/null || true) + + if [[ -z "$token" ]]; then + local err + err=$(echo "$response" | jq -r '.details // .message // empty' 2>/dev/null || true) + [[ -n "$err" ]] && die_auth "Authentication failed: $err" + die_auth "Authentication failed for $registry. Check your credentials." + fi + + local expires_in + expires_in=$(echo "$response" | jq -r '.expires_in // 55' 2>/dev/null || echo "55") + + local cache_key="${registry}|${scope}" + HM_TOKEN_CACHE["$cache_key"]="$token" + HM_TOKEN_EXPIRY["$cache_key"]=$(( $(date +%s) + expires_in - 5 )) + + echo "$token" +} + +get_bearer_token() { + local registry="$1" + local scope="$2" + local username="${3:-$HM_USERNAME}" + local password="${4:-$HM_PASSWORD}" + + local cache_key="${registry}|${scope}" + local now + now=$(date +%s) + + if [[ -v "HM_TOKEN_CACHE[$cache_key]" ]]; then + local expiry="${HM_TOKEN_EXPIRY[$cache_key]:-0}" + if (( now < expiry )); then + echo "${HM_TOKEN_CACHE[$cache_key]}" + return 0 + fi + fi + + _fetch_bearer_token "$registry" "$scope" "$username" "$password" +} + +get_basic_auth_header() { + local username="${1:-$HM_USERNAME}" + local password="${2:-$HM_PASSWORD}" + [[ -n "$username" && -n "$password" ]] || return 1 + printf "Authorization: Basic %s" "$(printf "%s:%s" "$username" "$password" | base64 | tr -d '\n')" +} + +# Build auth header for a registry+scope; handles bearer and basic transparently +make_auth_header() { + local registry="$1" + local scope="${2:-}" + local username="${3:-$HM_USERNAME}" + local password="${4:-$HM_PASSWORD}" + + probe_registry_auth "$registry" + local mode="${HM_AUTH_MODE[$registry]:-none}" + + case "$mode" in + bearer) + if [[ -n "$scope" ]]; then + local token + token=$(get_bearer_token "$registry" "$scope" "$username" "$password") || return 1 + echo "Authorization: Bearer $token" + fi + ;; + basic) + get_basic_auth_header "$username" "$password" || true + ;; + none) + echo "" + ;; + esac +} + +# ============================================================================= +# --- Registry request --- +# ============================================================================= + +# registry_request METHOD REGISTRY PATH [options] +# Options: +# --scope SCOPE auth scope (required for bearer registries) +# -H "Header: Val" extra request header +# -o FILE write body to file +# --data DATA request body string +# --data-binary @FILE request body from file +# --no-die skip error handling (check HM_LAST_HTTP_CODE manually) +# Sets: HM_LAST_HTTP_CODE, HM_LAST_HEADERS_FILE +# Outputs: response body to stdout +registry_request() { + local method="$1" + local registry="$2" + local path="$3" + shift 3 + + local scope="" + local no_die=false + local -a headers=() + local -a extra=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --scope) scope="$2"; shift 2 ;; + --no-die) no_die=true; shift ;; + -H) headers+=("-H" "$2"); shift 2 ;; + -o) extra+=("-o" "$2"); shift 2 ;; + --data) extra+=("--data" "$2"); shift 2 ;; + --data-binary) extra+=("--data-binary" "$2"); shift 2 ;; + *) extra+=("$1"); shift ;; + esac + done + + local url="${registry}${path}" + + # Get auth header + local auth_header + auth_header=$(make_auth_header "$registry" "$scope") || auth_header="" + + local -a auth_args=() + [[ -n "$auth_header" ]] && auth_args=("-H" "$auth_header") + + local body + body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}") + + # On 401: refresh token using the challenge scope and retry once + if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then + local www_auth + www_auth=$(get_response_header "www-authenticate") + local challenge_scope + challenge_scope=$(echo "$www_auth" | grep -oi 'scope="[^"]*"' | cut -d'"' -f2 || true) + local retry_scope="${scope:-$challenge_scope}" + + if [[ -n "$retry_scope" && -n "$www_auth" ]]; then + verbose "401 received, retrying with scope: $retry_scope" + # Invalidate cache + unset "HM_TOKEN_CACHE[${registry}|${retry_scope}]" || true + + local retry_auth + retry_auth=$(make_auth_header "$registry" "$retry_scope") || retry_auth="" + local -a retry_auth_args=() + [[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth") + + body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}") + fi + fi + + if [[ "$no_die" == false ]]; then + _handle_http_error "$HM_LAST_HTTP_CODE" "$body" "$url" + fi + + echo "$body" +} + +_handle_http_error() { + local code="$1" + local body="$2" + local url="$3" + local msg + + case "$code" in + 2*|304) return 0 ;; + 401) + msg=$(_extract_registry_error "$body") + die_auth "Authentication required${msg:+: $msg}" + ;; + 403) + msg=$(_extract_registry_error "$body") + die_permission "Access denied${msg:+: $msg}" + ;; + 404) + msg=$(_extract_registry_error "$body") + die_notfound "Not found: $url${msg:+. $msg}" + ;; + 405) + die_notsup "Operation not supported by this registry." + ;; + 5*) + msg=$(_extract_registry_error "$body") + die_server "Registry server error $code${msg:+: $msg}" + ;; + 000) + die_network "Cannot connect to ${url%%/v2/*}. Check the URL and your network." + ;; + *) + die "Unexpected HTTP $code from $url" + ;; + esac +} + +_extract_registry_error() { + echo "$1" | jq -r '.errors[0].message // empty' 2>/dev/null || true +} + +# ============================================================================= +# --- Manifest Accept headers (used by multiple subcommands) --- +# ============================================================================= +MANIFEST_ACCEPT_HEADERS=( + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" + -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" + -H "Accept: application/vnd.oci.image.manifest.v1+json" + -H "Accept: application/vnd.oci.image.index.v1+json" +) + +# Normalize image name: add library/ prefix for Docker Hub official images +normalize_image() { + local image="$1" + local registry="${2:-$HM_REGISTRY}" + if is_dockerhub "$registry" && [[ "$image" != *"/"* ]]; then + echo "library/$image" + else + echo "$image" + fi +} + +# ============================================================================= +# --- Docker Hub REST API --- +# ============================================================================= +get_hub_api_token() { + [[ -n "$HM_HUB_TOKEN" ]] && { echo "$HM_HUB_TOKEN"; return 0; } + + [[ -n "$HM_USERNAME" && -n "$HM_PASSWORD" ]] || \ + die_auth "Docker Hub credentials required for this operation. Use --user and --password." + + local response + response=$(curl -s -X POST "${HM_DOCKERHUB_API}/v2/users/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"${HM_USERNAME}\",\"password\":\"${HM_PASSWORD}\"}" \ + 2>/dev/null) || die_network "Cannot connect to Docker Hub API." + + HM_HUB_TOKEN=$(echo "$response" | jq -r '.token // empty' 2>/dev/null || true) + + if [[ -z "$HM_HUB_TOKEN" ]]; then + local err + err=$(echo "$response" | jq -r '.detail // .message // empty' 2>/dev/null || true) + die_auth "Docker Hub login failed${err:+: $err}" + fi + + echo "$HM_HUB_TOKEN" +} + +# ============================================================================= +# --- Subcommand: help --- +# ============================================================================= +show_usage() { + cat < [COMMAND OPTIONS] + +${C_BOLD}Options:${C_RESET} + -r, --registry Registry URL (default: https://registry-1.docker.io) + -u, --user Username + -p, --password Password or token + --config Config file (default: ~/.hubmanager.conf) + --json Output raw JSON + --no-color Disable color output + -v, --verbose Verbose mode + -q, --quiet Suppress non-error output + -h, --help Show this help + --version Show version + +${C_BOLD}Commands:${C_RESET} + list List repositories in the registry + tags List tags for an image + inspect : Show image manifest details + delete : Delete an image tag or manifest + copy Copy/retag an image + prune Delete outdated tags for an image + login Test credentials and optionally save to config + +Run ${C_BOLD}hubmanager --help${C_RESET} for command-specific options. +EOF +} + +cmd_help() { show_usage; } + +# ============================================================================= +# --- Subcommand: version --- +# ============================================================================= +cmd_version() { echo "hubmanager $HM_VERSION"; } + +# ============================================================================= +# --- Subcommand: login --- +# ============================================================================= +cmd_login() { + local do_save=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --save) do_save=true; shift ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] + +Test credentials against the registry. + +Options: + --save Write validated credentials to the config file (~/.hubmanager.conf) +EOF + exit 0 ;; + *) die "Unknown option: $1. Run 'hubmanager login --help'." ;; + esac + done + + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + probe_registry_auth "$HM_REGISTRY" + local mode="${HM_AUTH_MODE[$HM_REGISTRY]}" + + case "$mode" in + bearer) + _fetch_bearer_token "$HM_REGISTRY" "registry:catalog:*" > /dev/null || \ + die_auth "Login failed for $HM_REGISTRY" + info "${C_GREEN}Login Succeeded${C_RESET} — bearer auth, registry: $HM_REGISTRY" + ;; + basic) + raw_http GET "${HM_REGISTRY}/v2/" -H "$(get_basic_auth_header)" > /dev/null + if [[ "$HM_LAST_HTTP_CODE" == "200" || "$HM_LAST_HTTP_CODE" == "401" ]]; then + # 401 on basic means wrong creds; 200 means ok + [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die_auth "Login failed: invalid credentials." + info "${C_GREEN}Login Succeeded${C_RESET} — basic auth, registry: $HM_REGISTRY" + else + die_auth "Login failed (HTTP $HM_LAST_HTTP_CODE)" + fi + ;; + none) + info "${C_GREEN}Login Succeeded${C_RESET} — no auth required, registry: $HM_REGISTRY" + ;; + esac + + if [[ "$do_save" == true ]]; then + local config="$HM_CONFIG_FILE" + { + echo "# hubmanager configuration" + echo "# Generated by: hubmanager login" + echo "" + echo "REGISTRY=${HM_REGISTRY}" + [[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}" + [[ -n "$HM_PASSWORD" ]] && echo "PASSWORD=${HM_PASSWORD}" + } > "$config" + chmod 600 "$config" + info "Credentials saved to $config" + fi +} + +# ============================================================================= +# --- Subcommand: list --- +# ============================================================================= +cmd_list() { + local limit=100 + local last="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --limit) limit="$2"; shift 2 ;; + --last) last="$2"; shift 2 ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager list [--limit N] [--last REPO] + +List repositories available in the registry. +On Docker Hub, lists repositories for the authenticated user. + +Options: + --limit N Page size (default: 100) + --last REPO Start listing after this repository (pagination cursor) +EOF + exit 0 ;; + *) die "Unknown option: $1. Run 'hubmanager list --help'." ;; + esac + done + + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + if is_dockerhub; then + _list_dockerhub "$limit" + else + _list_v2 "$limit" "$last" + fi +} + +_list_dockerhub() { + local page_size="$1" + + [[ -n "$HM_USERNAME" ]] || die_auth "Docker Hub username required for listing repositories." + + local hub_token + hub_token=$(get_hub_api_token) + + if [[ "$HM_OPT_JSON" == true ]]; then + curl -s -H "Authorization: JWT $hub_token" \ + "${HM_DOCKERHUB_API}/v2/repositories/${HM_USERNAME}/?page_size=${page_size}" \ + 2>/dev/null || die_network "Cannot reach Docker Hub API." + return + fi + + printf "${C_BOLD}%-60s %-12s %s${C_RESET}\n" "REPOSITORY" "PULLS" "STARS" + + local url="${HM_DOCKERHUB_API}/v2/repositories/${HM_USERNAME}/?page_size=${page_size}" + while [[ -n "$url" && "$url" != "null" ]]; do + local response + response=$(curl -s -H "Authorization: JWT $hub_token" "$url" 2>/dev/null) || \ + die_network "Cannot reach Docker Hub API." + + echo "$response" | jq -r \ + '.results[] | [.name, (.pull_count // 0 | tostring), (.star_count // 0 | tostring)] | @tsv' \ + 2>/dev/null | \ + while IFS=$'\t' read -r name pulls stars; do + printf "%-60s %-12s %s\n" "${HM_USERNAME}/${name}" "$pulls" "$stars" + done + + url=$(echo "$response" | jq -r '.next // empty' 2>/dev/null || true) + done +} + +_list_v2() { + local limit="$1" + local last="$2" + + local path="/v2/_catalog?n=${limit}" + [[ -n "$last" ]] && path+="&last=${last}" + + local body + body=$(registry_request GET "$HM_REGISTRY" "$path" --scope "registry:catalog:*") + + if [[ "$HM_OPT_JSON" == true ]]; then + echo "$body" + return + fi + + printf "${C_BOLD}%-60s${C_RESET}\n" "REPOSITORY" + echo "$body" | jq -r '.repositories[]? // empty' + + # Follow pagination via Link header + local link + link=$(get_response_header "link") || true + if [[ -n "$link" ]]; then + local next_last + next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true) + [[ -n "$next_last" ]] && _list_v2 "$limit" "$next_last" + fi +} + +# ============================================================================= +# --- Subcommand: tags --- +# ============================================================================= +cmd_tags() { + local image="" + local limit=100 + local last="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --limit) limit="$2"; shift 2 ;; + --last) last="$2"; shift 2 ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager tags [--limit N] [--last TAG] + +List all tags for an image. + +Options: + --limit N Page size (default: 100) + --last TAG Pagination cursor +EOF + exit 0 ;; + -*) die "Unknown option: $1. Run 'hubmanager tags --help'." ;; + *) image="$1"; shift ;; + esac + done + + [[ -z "$image" ]] && die "Image name required. Usage: hubmanager tags " + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + local name + name=$(normalize_image "$image") + + _tags_page "$name" "$limit" "$last" +} + +_tags_page() { + local name="$1" + local limit="$2" + local last="$3" + + local path="/v2/${name}/tags/list?n=${limit}" + [[ -n "$last" ]] && path+="&last=${last}" + + local body + body=$(registry_request GET "$HM_REGISTRY" "$path" \ + --scope "repository:${name}:pull") + + if [[ "$HM_OPT_JSON" == true ]]; then + echo "$body" + return + fi + + # Print header only on first page + [[ -z "$last" ]] && printf "${C_BOLD}%-40s${C_RESET}\n" "TAG" + echo "$body" | jq -r '.tags[]? // empty' + + # Follow pagination + local link + link=$(get_response_header "link") || true + if [[ -n "$link" ]]; then + local next_last + next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true) + [[ -n "$next_last" ]] && _tags_page "$name" "$limit" "$next_last" + fi +} + +# ============================================================================= +# --- Subcommand: inspect --- +# ============================================================================= +cmd_inspect() { + local ref="" + local platform="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --platform) platform="$2"; shift 2 ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager inspect : [--platform OS/ARCH] + +Show detailed image information: digest, size, layers, OS/arch, labels. + +Options: + --platform OS/ARCH For multi-arch images, inspect a specific platform + Example: --platform linux/amd64 +EOF + exit 0 ;; + -*) die "Unknown option: $1. Run 'hubmanager inspect --help'." ;; + *) ref="$1"; shift ;; + esac + done + + [[ -z "$ref" ]] && die "Image reference required. Usage: hubmanager inspect :" + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + _do_inspect "$ref" "$platform" +} + +_do_inspect() { + local ref="$1" + local platform="${2:-}" + + # Parse image:tag or image@digest + local image tag + if [[ "$ref" == *"@"* ]]; then + image="${ref%%@*}"; tag="${ref#*@}" + elif [[ "$ref" == *":"* ]]; then + image="${ref%%:*}"; tag="${ref#*:}" + else + image="$ref"; tag="latest" + fi + + image=$(normalize_image "$image") + local scope="repository:${image}:pull" + + # Fetch manifest (GET with multi-Accept; headers give us the digest) + local manifest_body + manifest_body=$(registry_request GET "$HM_REGISTRY" "/v2/${image}/manifests/${tag}" \ + --scope "$scope" \ + "${MANIFEST_ACCEPT_HEADERS[@]}") + + local digest + digest=$(get_response_header "docker-content-digest") || true + + local content_type + content_type=$(get_response_header "content-type") || true + [[ -z "$content_type" ]] && \ + content_type=$(echo "$manifest_body" | jq -r '.mediaType // "unknown"' 2>/dev/null || true) + + if [[ "$HM_OPT_JSON" == true ]]; then + echo "$manifest_body" | jq --arg digest "$digest" '. + {_digest: $digest}' + return + fi + + print_kv "Image" "${image}:${tag}" + [[ -n "$digest" ]] && print_kv "Digest" "$digest" + print_kv "MediaType" "$content_type" + + # Multi-arch manifest list? + local schema_type + schema_type=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true) + + if echo "$schema_type" | grep -qE "manifest\.list|image\.index"; then + echo "" + printf "${C_BOLD}%-22s %-12s %-12s %s${C_RESET}\n" "DIGEST" "OS" "ARCH" "SIZE" + echo "$manifest_body" | jq -r \ + '.manifests[] | [.digest, (.platform.os // "?"), (.platform.architecture // "?"), (.size // 0 | tostring)] | @tsv' \ + 2>/dev/null | \ + while IFS=$'\t' read -r d os arch sz; do + printf "%.22s %-12s %-12s %s\n" "$d" "$os" "$arch" "$(human_size "$sz")" + done + + if [[ -n "$platform" ]]; then + local plat_os="${platform%%/*}" + local plat_arch="${platform#*/}" + local plat_digest + plat_digest=$(echo "$manifest_body" | jq -r \ + --arg os "$plat_os" --arg arch "$plat_arch" \ + '.manifests[] | select(.platform.os == $os and .platform.architecture == $arch) | .digest' \ + 2>/dev/null || true) + if [[ -n "$plat_digest" ]]; then + echo "" + echo "${C_BOLD}Platform: $platform${C_RESET}" + _do_inspect "${image}@${plat_digest}" + else + warn "Platform '$platform' not found in manifest list." + fi + fi + return + fi + + # Single-arch manifest + local total_size + total_size=$(echo "$manifest_body" | jq '[.layers[].size // 0] | add // 0' 2>/dev/null || echo "0") + print_kv "CompressedSize" "$(human_size "$total_size") ($total_size bytes)" + + # Config blob -> OS/arch, created, labels + local config_digest + config_digest=$(echo "$manifest_body" | jq -r '.config.digest // empty' 2>/dev/null || true) + + if [[ -n "$config_digest" ]]; then + local config_body + config_body=$(registry_request GET "$HM_REGISTRY" "/v2/${image}/blobs/${config_digest}" \ + --scope "$scope") + + local os arch created + os=$(echo "$config_body" | jq -r '.os // empty' 2>/dev/null || true) + arch=$(echo "$config_body" | jq -r '.architecture // empty' 2>/dev/null || true) + created=$(echo "$config_body" | jq -r '.created // empty' 2>/dev/null || true) + + [[ -n "$os" && -n "$arch" ]] && print_kv "OS/Arch" "${os}/${arch}" + [[ -n "$created" ]] && print_kv "Created" "$created" + + local labels + labels=$(echo "$config_body" | \ + jq -r '.config.Labels // {} | to_entries[] | " \(.key)=\(.value)"' 2>/dev/null || true) + if [[ -n "$labels" ]]; then + print_kv "Labels" "" + echo "$labels" + fi + fi + + # Layers + local layer_count + layer_count=$(echo "$manifest_body" | jq '.layers | length' 2>/dev/null || echo "0") + print_kv "Layers" "$layer_count" + echo "$manifest_body" | jq -r \ + '.layers // [] | to_entries[] | + " [" + (.key | tostring) + "] " + .value.digest + + " (" + (.value.size // 0 | tostring) + " bytes)"' \ + 2>/dev/null || true +} + +# ============================================================================= +# --- Subcommand: delete --- +# ============================================================================= +cmd_delete() { + local ref="" + local yes=false + + while [[ $# -gt 0 ]]; do + case "$1" in + -y|--yes) yes=true; shift ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager delete : [--yes] + +Delete a tag or manifest from the registry. +Note: requires REGISTRY_STORAGE_DELETE_ENABLED=true on self-hosted registries. +Note: Docker Hub does not support deletion via the v2 API. + +Options: + -y, --yes Skip confirmation prompt +EOF + exit 0 ;; + -*) die "Unknown option: $1. Run 'hubmanager delete --help'." ;; + *) ref="$1"; shift ;; + esac + done + + [[ -z "$ref" ]] && die "Image reference required. Usage: hubmanager delete :" + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + is_dockerhub && die_notsup \ + "Docker Hub does not support image deletion via the v2 API. Use the web UI at https://hub.docker.com." + + # Parse image:tag or image@digest + local image tag_or_digest is_digest=false + if [[ "$ref" == *"@"* ]]; then + image="${ref%%@*}"; tag_or_digest="${ref#*@}"; is_digest=true + elif [[ "$ref" == *":"* ]]; then + image="${ref%%:*}"; tag_or_digest="${ref#*:}" + else + die "Reference must include a tag or digest. Example: myimage:latest or myimage@sha256:..." + fi + + image=$(normalize_image "$image") + local scope="repository:${image}:push" + + # Resolve tag to digest + local digest + if [[ "$is_digest" == true ]]; then + digest="$tag_or_digest" + else + registry_request HEAD "$HM_REGISTRY" "/v2/${image}/manifests/${tag_or_digest}" \ + --scope "$scope" "${MANIFEST_ACCEPT_HEADERS[@]}" > /dev/null + digest=$(get_response_header "docker-content-digest") || true + [[ -n "$digest" ]] || die "Could not resolve tag '$tag_or_digest' to a digest." + fi + + # Confirm + if [[ "$yes" == false ]]; then + echo "About to delete: ${C_BOLD}${image}${C_RESET} @ ${C_YELLOW}${digest}${C_RESET}" + echo "Registry: $HM_REGISTRY" + printf "Type 'yes' to confirm: " + local confirm + read -r confirm + [[ "$confirm" == "yes" ]] || { info "Aborted."; exit 0; } + fi + + registry_request DELETE "$HM_REGISTRY" "/v2/${image}/manifests/${digest}" \ + --scope "$scope" --no-die > /dev/null + + case "$HM_LAST_HTTP_CODE" in + 200|202) + if [[ "$HM_OPT_JSON" == true ]]; then + echo "{\"deleted\":true,\"digest\":\"${digest}\"}" + else + info "${C_GREEN}Deleted:${C_RESET} ${image} @ ${digest}" + fi + ;; + 405) + die_notsup "Deletion not enabled on this registry. For self-hosted registries, set REGISTRY_STORAGE_DELETE_ENABLED=true." + ;; + *) + die "Delete failed (HTTP $HM_LAST_HTTP_CODE)" + ;; + esac +} + +# ============================================================================= +# --- Subcommand: copy --- +# ============================================================================= +cmd_copy() { + local src="" dst="" + local src_registry="" dst_registry="" + local src_user="" src_password="" + local dst_user="" dst_password="" + local platform="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --src-registry) src_registry="$2"; shift 2 ;; + --dst-registry) dst_registry="$2"; shift 2 ;; + --src-user) src_user="$2"; shift 2 ;; + --src-password) src_password="$2"; shift 2 ;; + --dst-user) dst_user="$2"; shift 2 ;; + --dst-password) dst_password="$2"; shift 2 ;; + --platform) platform="$2"; shift 2 ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager copy : : [options] + +Copy/retag an image within or across registries. + +Options: + --src-registry URL Source registry (default: global --registry) + --dst-registry URL Destination registry (default: global --registry) + --src-user USER Source registry username + --src-password PASS Source registry password + --dst-user USER Destination registry username + --dst-password PASS Destination registry password + --platform OS/ARCH Copy a specific platform from a multi-arch image +EOF + exit 0 ;; + -*) die "Unknown option: $1. Run 'hubmanager copy --help'." ;; + *) + if [[ -z "$src" ]]; then src="$1" + elif [[ -z "$dst" ]]; then dst="$1" + else die "Too many arguments." + fi + shift ;; + esac + done + + [[ -z "$src" ]] && die "Source image required." + [[ -z "$dst" ]] && die "Destination image required." + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + # Apply defaults + [[ -z "$src_registry" ]] && src_registry="$HM_REGISTRY" + [[ -z "$dst_registry" ]] && dst_registry="$HM_REGISTRY" + [[ -z "$src_user" ]] && src_user="$HM_USERNAME" + [[ -z "$src_password" ]] && src_password="$HM_PASSWORD" + [[ -z "$dst_user" ]] && dst_user="$HM_USERNAME" + [[ -z "$dst_password" ]] && dst_password="$HM_PASSWORD" + + # Parse refs + local src_image src_tag dst_image dst_tag + if [[ "$src" == *":"* ]]; then src_image="${src%%:*}"; src_tag="${src#*:}" + else src_image="$src"; src_tag="latest"; fi + if [[ "$dst" == *":"* ]]; then dst_image="${dst%%:*}"; dst_tag="${dst#*:}" + else dst_image="$dst"; dst_tag="latest"; fi + + src_image=$(normalize_image "$src_image" "$src_registry") + dst_image=$(normalize_image "$dst_image" "$dst_registry") + + local src_scope="repository:${src_image}:pull" + local dst_scope="repository:${dst_image}:push" + + info "Copying ${C_BOLD}${src_image}:${src_tag}${C_RESET} → ${C_BOLD}${dst_image}:${dst_tag}${C_RESET}" + + # --- Fetch source manifest --- + local src_auth_header + src_auth_header=$(make_auth_header "$src_registry" "$src_scope" "$src_user" "$src_password") || src_auth_header="" + local -a src_auth=(); [[ -n "$src_auth_header" ]] && src_auth=("-H" "$src_auth_header") + + local manifest_body + manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \ + "${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}") + [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)" + + local content_type + content_type=$(get_response_header "content-type") || true + [[ -z "$content_type" ]] && \ + content_type=$(echo "$manifest_body" | jq -r '.mediaType // "application/vnd.docker.distribution.manifest.v2+json"' 2>/dev/null || true) + + # Handle platform selection from a manifest list + local schema_type + schema_type=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true) + if echo "$schema_type" | grep -qE "manifest\.list|image\.index" && [[ -n "$platform" ]]; then + local plat_os="${platform%%/*}" plat_arch="${platform#*/}" + local plat_digest + plat_digest=$(echo "$manifest_body" | jq -r \ + --arg os "$plat_os" --arg arch "$plat_arch" \ + '.manifests[] | select(.platform.os == $os and .platform.architecture == $arch) | .digest' \ + 2>/dev/null || true) + [[ -n "$plat_digest" ]] || die "Platform '$platform' not found in manifest list." + + manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \ + "${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}") + [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch platform manifest (HTTP $HM_LAST_HTTP_CODE)" + content_type=$(get_response_header "content-type") || true + fi + + # Save manifest bytes exactly (do not let jq reformat) + local manifest_file + manifest_file=$(mktemp); HM_TMPFILES+=("$manifest_file") + printf '%s' "$manifest_body" > "$manifest_file" + + # --- Destination auth --- + local dst_auth_header + dst_auth_header=$(make_auth_header "$dst_registry" "$dst_scope" "$dst_user" "$dst_password") || dst_auth_header="" + local -a dst_auth=(); [[ -n "$dst_auth_header" ]] && dst_auth=("-H" "$dst_auth_header") + + local same_registry=false + [[ "$src_registry" == "$dst_registry" ]] && same_registry=true + + # --- Transfer blobs (skip for manifest lists) --- + local final_schema + final_schema=$(echo "$manifest_body" | jq -r '.mediaType // ""' 2>/dev/null || true) + if ! echo "$final_schema" | grep -qE "manifest\.list|image\.index"; then + local blobs + blobs=$(echo "$manifest_body" | \ + jq -r '([.config] + (.layers // [])) | .[].digest' 2>/dev/null || true) + + local total_blobs i=1 + total_blobs=$(echo "$blobs" | grep -c . || true) + + while IFS= read -r blob_digest; do + [[ -z "$blob_digest" ]] && continue + info " Blob $i/$total_blobs: ${blob_digest:7:16}…" + + if [[ "$same_registry" == true && "$src_image" != "$dst_image" ]]; then + # Attempt cross-repo blob mount (same registry, different repo) + raw_http POST \ + "${dst_registry}/v2/${dst_image}/blobs/uploads/?mount=${blob_digest}&from=${src_image}" \ + "${dst_auth[@]}" > /dev/null 2>/dev/null || true + if [[ "$HM_LAST_HTTP_CODE" == "201" ]]; then + verbose " Blob mounted (no transfer needed)" + : $(( i++ )) + continue + fi + fi + + # Check if blob already at destination + raw_http HEAD "${dst_registry}/v2/${dst_image}/blobs/${blob_digest}" \ + "${dst_auth[@]}" > /dev/null 2>/dev/null || true + if [[ "$HM_LAST_HTTP_CODE" == "200" ]]; then + verbose " Blob already exists at destination" + : $(( i++ )) + continue + fi + + # Download to temp file, then upload + local blob_file + blob_file=$(mktemp); HM_TMPFILES+=("$blob_file") + + verbose " Downloading blob…" + raw_http GET "${src_registry}/v2/${src_image}/blobs/${blob_digest}" \ + "${src_auth[@]}" -o "$blob_file" > /dev/null + [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to download blob $blob_digest (HTTP $HM_LAST_HTTP_CODE)" + + # Initiate upload + raw_http POST "${dst_registry}/v2/${dst_image}/blobs/uploads/" \ + "${dst_auth[@]}" > /dev/null + local upload_url + upload_url=$(get_response_header "location") || true + [[ -n "$upload_url" ]] || die "Failed to initiate blob upload (HTTP $HM_LAST_HTTP_CODE)" + [[ "$upload_url" != http* ]] && upload_url="${dst_registry}${upload_url}" + + # Upload + verbose " Uploading blob…" + local sep="?"; [[ "$upload_url" == *"?"* ]] && sep="&" + raw_http PUT "${upload_url}${sep}digest=${blob_digest}" \ + "${dst_auth[@]}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${blob_file}" > /dev/null + [[ "$HM_LAST_HTTP_CODE" == "201" ]] || \ + die "Failed to upload blob $blob_digest (HTTP $HM_LAST_HTTP_CODE)" + + : $(( i++ )) + done <<< "$blobs" + fi + + # --- Push manifest --- + info " Pushing manifest…" + raw_http PUT "${dst_registry}/v2/${dst_image}/manifests/${dst_tag}" \ + "${dst_auth[@]}" \ + -H "Content-Type: ${content_type}" \ + --data-binary "@${manifest_file}" > /dev/null + + [[ "$HM_LAST_HTTP_CODE" == "200" || "$HM_LAST_HTTP_CODE" == "201" ]] || \ + die "Failed to push manifest (HTTP $HM_LAST_HTTP_CODE)" + + local result_digest + result_digest=$(get_response_header "docker-content-digest") || true + + if [[ "$HM_OPT_JSON" == true ]]; then + echo "{\"copied\":true,\"src\":\"${src_image}:${src_tag}\",\"dst\":\"${dst_image}:${dst_tag}\",\"digest\":\"${result_digest}\"}" + else + info "${C_GREEN}Copied successfully${C_RESET}" + [[ -n "$result_digest" ]] && info "Digest: $result_digest" + fi +} + +# ============================================================================= +# --- Subcommand: prune --- +# ============================================================================= +cmd_prune() { + local image="" + local keep=3 + local older_than="" + local exclude_pattern="^(latest|stable|main|master|release)$" + local dry_run=false + local yes=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --keep) keep="$2"; shift 2 ;; + --older-than) older_than="$2"; shift 2 ;; + --exclude) exclude_pattern="$2"; shift 2 ;; + --no-exclude) exclude_pattern=""; shift ;; + -n|--dry-run) dry_run=true; shift ;; + -y|--yes) yes=true; shift ;; + -h|--help) + cat <<'EOF' +Usage: hubmanager prune [options] + +Delete outdated tags for an image. Tags are sorted by image creation date +(newest first). The N most recent are kept; the rest are deleted. + +Options: + --keep N Number of tags to keep (default: 3) + --older-than DAYS Only delete tags older than N days (overrides --keep) + --exclude PATTERN Extended regex of tag names to never delete + (default: "^(latest|stable|main|master|release)$") + --no-exclude Disable the default exclusion pattern + -n, --dry-run Show what would be deleted without actually deleting + -y, --yes Skip confirmation prompt + +Examples: + hubmanager prune myuser/myapp --keep 5 + hubmanager prune myuser/myapp --older-than 30 --dry-run + hubmanager prune myuser/myapp --keep 3 --exclude "^(latest|v[0-9]+\.[0-9]+)$" +EOF + exit 0 ;; + -*) die "Unknown option: $1. Run 'hubmanager prune --help'." ;; + *) image="$1"; shift ;; + esac + done + + [[ -z "$image" ]] && die "Image name required. Usage: hubmanager prune " + [[ -z "$HM_REGISTRY" ]] && HM_REGISTRY="$HM_DEFAULT_REGISTRY" + + is_dockerhub && die_notsup \ + "Docker Hub does not support deletion via the v2 API. Use the web UI at https://hub.docker.com." + + local name + name=$(normalize_image "$image") + + info "Fetching tags for ${C_BOLD}${name}${C_RESET}…" + + # Collect all tags + local all_tags=() + while IFS= read -r tag; do + [[ -n "$tag" ]] && all_tags+=("$tag") + done < <(_collect_tags "$name") + + local total_tags="${#all_tags[@]}" + [[ "$total_tags" -eq 0 ]] && { info "No tags found."; exit 0; } + info "Found $total_tags tags." + + # Apply exclusion filter + local filtered_tags=() + local excluded_tags=() + for tag in "${all_tags[@]}"; do + if [[ -n "$exclude_pattern" ]] && echo "$tag" | grep -qE "$exclude_pattern"; then + excluded_tags+=("$tag") + else + filtered_tags+=("$tag") + fi + done + + if (( ${#excluded_tags[@]} > 0 )); then + verbose "Excluded tags (won't be deleted): ${excluded_tags[*]}" + fi + + if (( ${#filtered_tags[@]} == 0 )); then + info "No eligible tags to prune (all excluded)." + exit 0 + fi + + # Fetch creation dates for filterable tags + info "Inspecting ${#filtered_tags[@]} tags to determine age…" + local scope="repository:${name}:pull" + + declare -A tag_dates=() # tag -> ISO8601 date string + declare -A tag_digests=() # tag -> digest + + local i=1 + for tag in "${filtered_tags[@]}"; do + printf "\r [%d/%d] %s…%-20s" "$i" "${#filtered_tags[@]}" "$tag" " " >&2 + + # Get manifest to fetch digest and config blob + local m_body + m_body=$(registry_request GET "$HM_REGISTRY" "/v2/${name}/manifests/${tag}" \ + --scope "$scope" "${MANIFEST_ACCEPT_HEADERS[@]}" 2>/dev/null) || { : $(( i++ )); continue; } + + local t_digest + t_digest=$(get_response_header "docker-content-digest") || true + [[ -n "$t_digest" ]] && tag_digests["$tag"]="$t_digest" + + # Get creation date from config blob + local cfg_digest created="" + cfg_digest=$(echo "$m_body" | jq -r '.config.digest // empty' 2>/dev/null || true) + if [[ -n "$cfg_digest" ]]; then + local cfg_body + cfg_body=$(registry_request GET "$HM_REGISTRY" "/v2/${name}/blobs/${cfg_digest}" \ + --scope "$scope" 2>/dev/null) || true + created=$(echo "$cfg_body" | jq -r '.created // empty' 2>/dev/null || true) + fi + + tag_dates["$tag"]="${created:-1970-01-01T00:00:00Z}" + : $(( i++ )) + done + echo "" >&2 # newline after progress + + # Sort tags by date (oldest first) + local sorted_tags + sorted_tags=$(for tag in "${!tag_dates[@]}"; do + echo "${tag_dates[$tag]} $tag" + done | sort | awk '{print $2}') + + # Determine tags to delete + local tags_to_delete=() + local cutoff_epoch=0 + + if [[ -n "$older_than" ]]; then + # Delete tags older than N days + cutoff_epoch=$(( $(date +%s) - older_than * 86400 )) + while IFS= read -r tag; do + local tag_date="${tag_dates[$tag]:-1970-01-01T00:00:00Z}" + local tag_epoch + tag_epoch=$(date -d "$tag_date" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$tag_date" +%s 2>/dev/null || echo "0") + if (( tag_epoch < cutoff_epoch )); then + tags_to_delete+=("$tag") + fi + done <<< "$sorted_tags" + else + # Keep the N most recent; delete the rest + local sorted_count + sorted_count=$(echo "$sorted_tags" | grep -c . || true) + if (( sorted_count <= keep )); then + info "Nothing to prune: only $sorted_count eligible tags (keeping $keep)." + exit 0 + fi + local to_delete_count=$(( sorted_count - keep )) + # Take oldest `to_delete_count` tags + while IFS= read -r tag; do + tags_to_delete+=("$tag") + done < <(echo "$sorted_tags" | head -n "$to_delete_count") + fi + + if (( ${#tags_to_delete[@]} == 0 )); then + info "Nothing to prune." + exit 0 + fi + + # Show plan + echo "" + printf "${C_BOLD}Tags to delete (%d):${C_RESET}\n" "${#tags_to_delete[@]}" + for tag in "${tags_to_delete[@]}"; do + local date_str="${tag_dates[$tag]:-unknown}" + printf " ${C_YELLOW}%-40s${C_RESET} %s\n" "$tag" "$date_str" + done + + if [[ "$dry_run" == true ]]; then + echo "" + info "${C_CYAN}Dry run — no tags were deleted.${C_RESET}" + exit 0 + fi + + if [[ "$yes" == false ]]; then + echo "" + printf "Delete ${C_BOLD}%d${C_RESET} tag(s) from ${C_BOLD}%s${C_RESET}? Type 'yes' to confirm: " \ + "${#tags_to_delete[@]}" "$name" + local confirm + read -r confirm + [[ "$confirm" == "yes" ]] || { info "Aborted."; exit 0; } + fi + + local delete_scope="repository:${name}:push" + local deleted=0 failed=0 + + for tag in "${tags_to_delete[@]}"; do + local digest="${tag_digests[$tag]:-}" + + # If we didn't capture the digest earlier, resolve it now + if [[ -z "$digest" ]]; then + registry_request HEAD "$HM_REGISTRY" "/v2/${name}/manifests/${tag}" \ + --scope "$delete_scope" "${MANIFEST_ACCEPT_HEADERS[@]}" > /dev/null 2>/dev/null || true + digest=$(get_response_header "docker-content-digest") || true + fi + + if [[ -z "$digest" ]]; then + warn "Could not resolve digest for tag '$tag', skipping." + : $(( failed++ )) + continue + fi + + registry_request DELETE "$HM_REGISTRY" "/v2/${name}/manifests/${digest}" \ + --scope "$delete_scope" --no-die > /dev/null + + case "$HM_LAST_HTTP_CODE" in + 200|202) + info "${C_GREEN}Deleted:${C_RESET} ${name}:${tag}" + : $(( deleted++ )) + ;; + 405) + die_notsup "Deletion not enabled on this registry. Set REGISTRY_STORAGE_DELETE_ENABLED=true." + ;; + *) + warn "Failed to delete ${name}:${tag} (HTTP $HM_LAST_HTTP_CODE)" + : $(( failed++ )) + ;; + esac + done + + echo "" + if [[ "$HM_OPT_JSON" == true ]]; then + echo "{\"deleted\":$deleted,\"failed\":$failed}" + else + info "Done. Deleted: ${C_GREEN}${deleted}${C_RESET}, Failed: ${failed}" + fi +} + +_collect_tags() { + local name="$1" + local scope="repository:${name}:pull" + local last="" + + while true; do + local path="/v2/${name}/tags/list?n=100" + [[ -n "$last" ]] && path+="&last=${last}" + + local body + body=$(registry_request GET "$HM_REGISTRY" "$path" --scope "$scope") + echo "$body" | jq -r '.tags[]? // empty' 2>/dev/null || true + + local link + link=$(get_response_header "link") || true + [[ -z "$link" ]] && break + + local next_last + next_last=$(echo "$link" | grep -o 'last=[^&>]*' | cut -d= -f2 | head -1 || true) + [[ -z "$next_last" ]] && break + last="$next_last" + done +} + +# ============================================================================= +# --- Global argument parsing --- +# ============================================================================= +parse_global_args() { + local -a remaining=() + + while [[ $# -gt 0 ]]; do + case "$1" in + -r|--registry) HM_REGISTRY="$2"; shift 2 ;; + -u|--user) HM_USERNAME="$2"; shift 2 ;; + -p|--password) HM_PASSWORD="$2"; shift 2 ;; + --config) HM_CONFIG_FILE="$2"; shift 2 ;; + --json) HM_OPT_JSON=true; shift ;; + --no-color) HM_OPT_NO_COLOR=true; shift ;; + -v|--verbose) HM_OPT_VERBOSE=true; shift ;; + -q|--quiet) HM_OPT_QUIET=true; shift ;; + -h|--help) show_usage; exit 0 ;; + --version) cmd_version; exit 0 ;; + --) shift; remaining+=("$@"); break ;; + # Stop global parsing at first non-flag arg or unknown flag + *) remaining+=("$@"); break ;; + esac + done + + HM_REMAINING=("${remaining[@]+"${remaining[@]}"}") +} + +# ============================================================================= +# --- Main dispatcher --- +# ============================================================================= +main() { + check_deps + + declare -ga HM_REMAINING=() + parse_global_args "$@" + set -- "${HM_REMAINING[@]+"${HM_REMAINING[@]}"}" + + load_config + [[ -n "$HM_REGISTRY" ]] && resolve_registry_alias + + setup_colors + + local command="${1:-help}" + [[ $# -gt 0 ]] && shift + + case "$command" in + list) cmd_list "$@" ;; + tags) cmd_tags "$@" ;; + inspect) cmd_inspect "$@" ;; + delete) cmd_delete "$@" ;; + copy) cmd_copy "$@" ;; + prune) cmd_prune "$@" ;; + login) cmd_login "$@" ;; + help|-h|--help) cmd_help ;; + version|--version) cmd_version ;; + *) + die "Unknown command: '$command'. Run 'hubmanager --help' for usage." + ;; + esac +} + +main "$@"