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>
This commit is contained in:
2026-03-01 04:49:50 +01:00
parent a59e416789
commit 0fb18377cb
3 changed files with 93 additions and 19 deletions

View File

@@ -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_<ALIAS>_URL/USERNAME/PASSWORD`
- Aliases are resolved in `resolve_registry_alias()` before any operations

View File

@@ -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> [COMMAND OPTIONS]
Default: https://registry-1.docker.io
-u, --user <username> Username (overrides config file)
-p, --password <pass> Password or token (overrides config file)
--config <file> Config file path (default: ~/.hubmanager.conf)
--config <file> 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 <https://hub.docker.com>.
- `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/<repo-name>
# 2. Run garbage collection to reclaim disk space
docker exec <registry-container> registry garbage-collect /etc/docker/registry/config.yml
# 3. Restart the registry so the catalog refreshes
docker restart <registry-container>
```
---
## JSON output

View File

@@ -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 <url> Registry URL (default: https://registry-1.docker.io)
-u, --user <username> Username
-p, --password <pass> Password or token
--config <file> Config file (default: ~/.hubmanager.conf)
--config <file> 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