diff --git a/CLAUDE.md b/CLAUDE.md index fbbc331..5c578ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,47 @@ authentication detection (bearer token or HTTP basic auth). --- +## What Changed (Session 2026-03-01) + +### Bug fixes and improvements (v0.2.1) + +1. **Global arg parsing fix** (`parse_global_args`): global flags like `--user`, + `--registry`, `--password` placed after the subcommand name (e.g. + `hubmanager login --user ...`) were not recognised. The `*` catch-all used + `remaining+=("$@"); break` which stopped parsing. Changed to + `remaining+=("$1"); shift` so parsing continues through all arguments. + +2. **Subshell variable fix** (`raw_http` / `registry_request`): `HM_LAST_HTTP_CODE` + and `HM_LAST_HEADERS_FILE` were set inside `raw_http`, but callers used + `body=$(raw_http ...)` which runs in a subshell — variables set inside never + propagated back. Fixed by adding two global temp files (`HM_HTTP_CODE_FILE`, + `HM_HEADERS_REF_FILE`) that `raw_http` writes to; callers read them back after + the subshell returns. + +3. **Config inline-comment stripping removed** (`load_config`): the parser stripped + everything after the first `#` in config values (`value="${value%%#*}"`), which + could truncate base64 ciphertext containing `#`. Removed since comment-only + lines are already skipped. + +4. **Decrypt newline fix** (`_decrypt_value`): `printf '%s'` piped ciphertext to + `openssl enc -d -a` without a trailing newline, causing `openssl` to fail with + "error reading input file". Changed to `printf '%s\n'`. + +5. **Config path moved**: `~/.hubmanager.conf` → `~/.config/hubmanager.conf`. + `login --save` now runs `mkdir -p` on the parent directory before writing. + +6. **README**: added "Cleaning up empty repositories" section explaining the + server-side steps needed to remove empty repos (registry v2 API limitation). + +### New global variables + +| Variable | Purpose | +| --- | --- | +| `HM_HTTP_CODE_FILE` | Temp file for HTTP code (survives subshells) | +| `HM_HEADERS_REF_FILE` | Temp file for headers path (survives subshells) | + +--- + ## What Changed (Session 2026-02-21) ### Encrypted config values (`--encrypt`) @@ -57,7 +98,7 @@ Both `load_config()` and `resolve_registry_alias()` detect the prefix and call | Command | Description | | --- | --- | -| `login` | Test credentials against a registry; `--save` writes to `~/.hubmanager.conf` | +| `login` | Test credentials against a registry; `--save` writes to `~/.config/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 | @@ -120,7 +161,7 @@ main "$@" - `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`): +6. **Config file** (`~/.config/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 diff --git a/README.md b/README.md index 89f7c4e..70054fa 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,12 @@ hubmanager prune myuser/myapp --keep 3 --dry-run ## Configuration -Credentials and registry settings are stored in `~/.hubmanager.conf`. +Credentials and registry settings are stored in `~/.config/hubmanager.conf`. The file uses a simple `KEY=VALUE` format: ```bash -# ~/.hubmanager.conf -# chmod 600 ~/.hubmanager.conf +# ~/.config/hubmanager.conf +# chmod 600 ~/.config/hubmanager.conf # Default registry and credentials REGISTRY=https://registry.example.com @@ -91,7 +91,7 @@ hubmanager login --registry https://registry.example.com \ # New master passphrase: **** # Confirm master passphrase: **** # Login Succeeded — bearer auth, registry: https://registry.example.com -# Credentials saved to /home/user/.hubmanager.conf +# Credentials saved to /home/user/.config/hubmanager.conf # Password stored encrypted (AES-256-CBC). Master passphrase required on each use. ``` @@ -116,7 +116,7 @@ hubmanager [OPTIONS] [COMMAND OPTIONS] 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) + --config Config file path (default: ~/.config/hubmanager.conf) --json Output raw JSON (pipe-friendly) --no-color Disable ANSI color -v, --verbose Show HTTP request details (with auth redacted) @@ -142,7 +142,7 @@ Add `--encrypt` to store the password encrypted with AES-256-CBC (requires `open 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 +# Credentials saved to /home/user/.config/hubmanager.conf ``` --- @@ -353,6 +353,25 @@ Bearer tokens are cached in memory for the duration of the session and refreshed - `delete` is not supported via the v2 API on Docker Hub. Use the web UI at . - `prune` is not supported on Docker Hub for the same reason. +### Cleaning up empty repositories + +The Docker Registry v2 API does not provide an endpoint to delete repositories. +After deleting all tags from a repository, it will still appear in `hubmanager list`. +This is a limitation of the registry spec, not of hubmanager. + +To remove empty repositories, you need direct access to the registry host: + +```bash +# 1. Delete the repository directory from storage +rm -rf /var/lib/registry/docker/registry/v2/repositories/ + +# 2. Run garbage collection to reclaim disk space +docker exec registry garbage-collect /etc/docker/registry/config.yml + +# 3. Restart the registry so the catalog refreshes +docker restart +``` + --- ## JSON output diff --git a/hubmanager b/hubmanager index 1d2662e..3a69e1f 100755 --- a/hubmanager +++ b/hubmanager @@ -1,6 +1,6 @@ #!/usr/bin/env bash # hubmanager - Manage Docker Registry images remotely -# Version: 0.2.0 +# Version: 0.2.1 # Dependencies: curl, jq, bash 4+ # Usage: hubmanager --help @@ -9,9 +9,9 @@ set -euo pipefail # ============================================================================= # --- Constants --- # ============================================================================= -readonly HM_VERSION="0.2.0" +readonly HM_VERSION="0.2.1" readonly HM_DEFAULT_REGISTRY="https://registry-1.docker.io" -readonly HM_DEFAULT_CONFIG="${HOME}/.hubmanager.conf" +readonly HM_DEFAULT_CONFIG="${HOME}/.config/hubmanager.conf" readonly HM_DOCKERHUB_API="https://hub.docker.com" # ============================================================================= @@ -38,9 +38,11 @@ HM_MASTER_PASS="" # Master passphrase for encrypted config values (s HM_TMPFILES=() HM_LAST_HTTP_CODE="" HM_LAST_HEADERS_FILE="" +HM_HTTP_CODE_FILE=$(mktemp) +HM_HEADERS_REF_FILE=$(mktemp) trap '_hm_cleanup' EXIT INT TERM -_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" 2>/dev/null || true; } +_hm_cleanup() { rm -f "${HM_TMPFILES[@]}" "$HM_HTTP_CODE_FILE" "$HM_HEADERS_REF_FILE" 2>/dev/null || true; } # ============================================================================= # --- Output / Formatting helpers --- @@ -152,7 +154,7 @@ _decrypt_value() { local pass_file result pass_file=$(mktemp); HM_TMPFILES+=("$pass_file") printf '%s' "$HM_MASTER_PASS" > "$pass_file" - if ! result=$(printf '%s' "$ciphertext" | \ + if ! result=$(printf '%s\n' "$ciphertext" | \ openssl enc -d -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null); then die "Failed to decrypt config value. Wrong master passphrase?" fi @@ -188,8 +190,7 @@ load_config() { key="${key%"${key##*[![:space:]]}"}" [[ -z "$key" ]] && continue - # Strip inline comments and trim whitespace from value - value="${value%%#*}" + # Trim whitespace from value value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" @@ -275,6 +276,10 @@ raw_http() { "$url" \ 2>/dev/null) || HM_LAST_HTTP_CODE="000" + # Persist code and headers path to files so they survive subshells + printf '%s' "$HM_LAST_HTTP_CODE" > "$HM_HTTP_CODE_FILE" + printf '%s' "$header_file" > "$HM_HEADERS_REF_FILE" + verbose "<<< HTTP $HM_LAST_HTTP_CODE" [[ "$method" != "HEAD" ]] && cat "$body_file" @@ -469,6 +474,8 @@ registry_request() { local body body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}") + HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE") + HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE") # On 401: refresh token using the challenge scope and retry once if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then @@ -489,6 +496,8 @@ registry_request() { [[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth") body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}") + HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE") + HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE") fi fi @@ -597,7 +606,7 @@ ${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) + --config Config file (default: ~/.config/hubmanager.conf) --json Output raw JSON --no-color Disable color output -v, --verbose Verbose mode @@ -643,7 +652,7 @@ 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) + --save Write validated credentials to the config file (~/.config/hubmanager.conf) --encrypt Encrypt the password in the config file with AES-256-CBC. A master passphrase will be prompted; you must enter the same passphrase on every subsequent hubmanager invocation that reads @@ -682,6 +691,7 @@ EOF if [[ "$do_save" == true ]]; then local config="$HM_CONFIG_FILE" + mkdir -p "$(dirname "$config")" local pw_entry="" if [[ -n "$HM_PASSWORD" ]]; then if [[ "$do_encrypt" == true ]]; then @@ -1187,6 +1197,8 @@ EOF 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=$(<"$HM_HTTP_CODE_FILE") + HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE") [[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)" local content_type @@ -1208,6 +1220,8 @@ EOF manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \ "${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}") + HM_LAST_HTTP_CODE=$(<"$HM_HTTP_CODE_FILE") + HM_LAST_HEADERS_FILE=$(<"$HM_HEADERS_REF_FILE") [[ "$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 @@ -1587,8 +1601,8 @@ parse_global_args() { -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 ;; + # Pass non-global args through, but keep parsing remaining args + *) remaining+=("$1"); shift ;; esac done