Files
hubmanager/CLAUDE.md
magdev e70596cd59 fix: resolve arg parsing, subshell variable loss, and decrypt issues (v0.2.1)
- Global flags (--user, --registry, --password) now work after subcommand name
- Fix raw_http variables lost in subshells by persisting to temp files
- Remove config inline-comment stripping that truncated base64 ciphertext
- Add trailing newline to openssl decrypt pipe input
- Move config path to ~/.config/hubmanager.conf
- Add "Cleaning up empty repositories" section to README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:49:50 +01:00

11 KiB

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 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)

Added openssl AES-256-CBC encryption for passwords stored in the config file. openssl is an optional dependency — it is only required when enc: prefixed values are present in the config, or when login --encrypt is used.

New functions (Encryption helpers section):

Function Purpose
_require_openssl() Die with a clear message if openssl is not installed
_prompt_master_pass() Prompt once per session via /dev/tty; cache in HM_MASTER_PASS
_prompt_set_master_pass() Prompt + confirm a new passphrase (used by login --encrypt)
_encrypt_value(plaintext) AES-256-CBC encrypt → base64 ciphertext (no newlines)
_decrypt_value(ciphertext) Decrypt base64 ciphertext → plaintext; die on wrong passphrase

Passphrase security: passed to openssl via a mktemp file (-pass file:) to avoid exposure in the process argument list (ps aux). The temp file is registered in HM_TMPFILES and removed on exit.

Config format: encrypted values use an enc: prefix, e.g.:

PASSWORD=enc:U2FsdGVkX1+...base64ciphertext...
REGISTRY_PROD_PASSWORD=enc:U2FsdGVkX1+...

Both load_config() and resolve_registry_alias() detect the prefix and call _decrypt_value transparently.

Version bump: 0.1.00.2.0


What Was Built (Session 2025-02-21)

Primary file

hubmanager — executable Bash script, ~680 lines, no mandatory dependencies beyond curl, jq, Bash 4+. openssl is required only when encrypted config values are used.

Subcommands implemented

Command Description
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
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

#!/usr/bin/env bash
set -euo pipefail
# --- Constants ---
# --- Global state ---
# --- Output / Formatting helpers ---
# --- Dependency check ---
# --- Encryption helpers ---   _encrypt_value(), _decrypt_value(), _prompt_master_pass()
# --- 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 <realm>?scope=S&service=S with -u user:pass → cache .token
    • Basic: construct Authorization: Basic <base64> directly
    • Tokens cached in HM_TOKEN_CACHE["registry|scope"] with epoch expiry
  5. Docker Hub differences:

    • list uses hub.docker.com/v2/repositories/<user>/ (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 (~/.config/hubmanager.conf):

    • KEY=VALUE format, parsed with while IFS='=' read (not source)
    • Supports named registry aliases: REGISTRY_<ALIAS>_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
  9. Encrypted config values (v0.2.0):

    • login --save --encrypt prompts for a master passphrase (with confirmation), encrypts the password with openssl enc -aes-256-cbc -pbkdf2 -a, and writes PASSWORD=enc:<b64>.
    • Passphrase is passed to openssl via a temp file (-pass file:) — never via argv or env.
    • load_config and resolve_registry_alias both check for the enc: prefix and call _decrypt_value, which triggers _prompt_master_pass (once per session, cached in HM_MASTER_PASS).
    • openssl is an optional dependency: not checked at startup, only on first enc: encounter.

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_MASTER_PASS Master passphrase for enc: config values (session-cached)
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_<name>() 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():

    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.