You've already forked hubmanager
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:
45
CLAUDE.md
45
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)
|
## What Changed (Session 2026-02-21)
|
||||||
|
|
||||||
### Encrypted config values (`--encrypt`)
|
### Encrypted config values (`--encrypt`)
|
||||||
@@ -57,7 +98,7 @@ Both `load_config()` and `resolve_registry_alias()` detect the prefix and call
|
|||||||
|
|
||||||
| Command | Description |
|
| 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) |
|
| `list` | List repositories (`_catalog` for self-hosted; Hub REST API for Docker Hub) |
|
||||||
| `tags` | List tags for an image with pagination |
|
| `tags` | List tags for an image with pagination |
|
||||||
| `inspect` | Show manifest digest, size, OS/arch, layers, labels; multi-arch support |
|
| `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)
|
- `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`
|
- 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`)
|
- `KEY=VALUE` format, parsed with `while IFS='=' read` (not `source`)
|
||||||
- Supports named registry aliases: `REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD`
|
- Supports named registry aliases: `REGISTRY_<ALIAS>_URL/USERNAME/PASSWORD`
|
||||||
- Aliases are resolved in `resolve_registry_alias()` before any operations
|
- Aliases are resolved in `resolve_registry_alias()` before any operations
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -48,12 +48,12 @@ hubmanager prune myuser/myapp --keep 3 --dry-run
|
|||||||
|
|
||||||
## Configuration
|
## 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:
|
The file uses a simple `KEY=VALUE` format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ~/.hubmanager.conf
|
# ~/.config/hubmanager.conf
|
||||||
# chmod 600 ~/.hubmanager.conf
|
# chmod 600 ~/.config/hubmanager.conf
|
||||||
|
|
||||||
# Default registry and credentials
|
# Default registry and credentials
|
||||||
REGISTRY=https://registry.example.com
|
REGISTRY=https://registry.example.com
|
||||||
@@ -91,7 +91,7 @@ hubmanager login --registry https://registry.example.com \
|
|||||||
# New master passphrase: ****
|
# New master passphrase: ****
|
||||||
# Confirm master passphrase: ****
|
# Confirm master passphrase: ****
|
||||||
# Login Succeeded — bearer auth, registry: https://registry.example.com
|
# 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.
|
# 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
|
Default: https://registry-1.docker.io
|
||||||
-u, --user <username> Username (overrides config file)
|
-u, --user <username> Username (overrides config file)
|
||||||
-p, --password <pass> Password or token (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)
|
--json Output raw JSON (pipe-friendly)
|
||||||
--no-color Disable ANSI color
|
--no-color Disable ANSI color
|
||||||
-v, --verbose Show HTTP request details (with auth redacted)
|
-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 \
|
hubmanager login --registry https://registry.example.com \
|
||||||
--user admin --password secret --save
|
--user admin --password secret --save
|
||||||
# Login Succeeded — bearer auth, registry: https://registry.example.com
|
# 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>.
|
- `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.
|
- `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
|
## JSON output
|
||||||
|
|||||||
36
hubmanager
36
hubmanager
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# hubmanager - Manage Docker Registry images remotely
|
# hubmanager - Manage Docker Registry images remotely
|
||||||
# Version: 0.2.0
|
# Version: 0.2.1
|
||||||
# Dependencies: curl, jq, bash 4+
|
# Dependencies: curl, jq, bash 4+
|
||||||
# Usage: hubmanager --help
|
# Usage: hubmanager --help
|
||||||
|
|
||||||
@@ -9,9 +9,9 @@ set -euo pipefail
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# --- Constants ---
|
# --- 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_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"
|
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_TMPFILES=()
|
||||||
HM_LAST_HTTP_CODE=""
|
HM_LAST_HTTP_CODE=""
|
||||||
HM_LAST_HEADERS_FILE=""
|
HM_LAST_HEADERS_FILE=""
|
||||||
|
HM_HTTP_CODE_FILE=$(mktemp)
|
||||||
|
HM_HEADERS_REF_FILE=$(mktemp)
|
||||||
|
|
||||||
trap '_hm_cleanup' EXIT INT TERM
|
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 ---
|
# --- Output / Formatting helpers ---
|
||||||
@@ -152,7 +154,7 @@ _decrypt_value() {
|
|||||||
local pass_file result
|
local pass_file result
|
||||||
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
|
||||||
printf '%s' "$HM_MASTER_PASS" > "$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
|
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?"
|
die "Failed to decrypt config value. Wrong master passphrase?"
|
||||||
fi
|
fi
|
||||||
@@ -188,8 +190,7 @@ load_config() {
|
|||||||
key="${key%"${key##*[![:space:]]}"}"
|
key="${key%"${key##*[![:space:]]}"}"
|
||||||
[[ -z "$key" ]] && continue
|
[[ -z "$key" ]] && continue
|
||||||
|
|
||||||
# Strip inline comments and trim whitespace from value
|
# Trim whitespace from value
|
||||||
value="${value%%#*}"
|
|
||||||
value="${value#"${value%%[![:space:]]*}"}"
|
value="${value#"${value%%[![:space:]]*}"}"
|
||||||
value="${value%"${value##*[![:space:]]}"}"
|
value="${value%"${value##*[![:space:]]}"}"
|
||||||
|
|
||||||
@@ -275,6 +276,10 @@ raw_http() {
|
|||||||
"$url" \
|
"$url" \
|
||||||
2>/dev/null) || HM_LAST_HTTP_CODE="000"
|
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"
|
verbose "<<< HTTP $HM_LAST_HTTP_CODE"
|
||||||
|
|
||||||
[[ "$method" != "HEAD" ]] && cat "$body_file"
|
[[ "$method" != "HEAD" ]] && cat "$body_file"
|
||||||
@@ -469,6 +474,8 @@ registry_request() {
|
|||||||
|
|
||||||
local body
|
local body
|
||||||
body=$(raw_http "$method" "$url" "${auth_args[@]}" "${headers[@]}" "${extra[@]}")
|
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
|
# On 401: refresh token using the challenge scope and retry once
|
||||||
if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then
|
if [[ "$HM_LAST_HTTP_CODE" == "401" ]]; then
|
||||||
@@ -489,6 +496,8 @@ registry_request() {
|
|||||||
[[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth")
|
[[ -n "$retry_auth" ]] && retry_auth_args=("-H" "$retry_auth")
|
||||||
|
|
||||||
body=$(raw_http "$method" "$url" "${retry_auth_args[@]}" "${headers[@]}" "${extra[@]}")
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -597,7 +606,7 @@ ${C_BOLD}Options:${C_RESET}
|
|||||||
-r, --registry <url> Registry URL (default: https://registry-1.docker.io)
|
-r, --registry <url> Registry URL (default: https://registry-1.docker.io)
|
||||||
-u, --user <username> Username
|
-u, --user <username> Username
|
||||||
-p, --password <pass> Password or token
|
-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
|
--json Output raw JSON
|
||||||
--no-color Disable color output
|
--no-color Disable color output
|
||||||
-v, --verbose Verbose mode
|
-v, --verbose Verbose mode
|
||||||
@@ -643,7 +652,7 @@ Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save
|
|||||||
Test credentials against the registry.
|
Test credentials against the registry.
|
||||||
|
|
||||||
Options:
|
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.
|
--encrypt Encrypt the password in the config file with AES-256-CBC.
|
||||||
A master passphrase will be prompted; you must enter the same
|
A master passphrase will be prompted; you must enter the same
|
||||||
passphrase on every subsequent hubmanager invocation that reads
|
passphrase on every subsequent hubmanager invocation that reads
|
||||||
@@ -682,6 +691,7 @@ EOF
|
|||||||
|
|
||||||
if [[ "$do_save" == true ]]; then
|
if [[ "$do_save" == true ]]; then
|
||||||
local config="$HM_CONFIG_FILE"
|
local config="$HM_CONFIG_FILE"
|
||||||
|
mkdir -p "$(dirname "$config")"
|
||||||
local pw_entry=""
|
local pw_entry=""
|
||||||
if [[ -n "$HM_PASSWORD" ]]; then
|
if [[ -n "$HM_PASSWORD" ]]; then
|
||||||
if [[ "$do_encrypt" == true ]]; then
|
if [[ "$do_encrypt" == true ]]; then
|
||||||
@@ -1187,6 +1197,8 @@ EOF
|
|||||||
local manifest_body
|
local manifest_body
|
||||||
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \
|
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${src_tag}" \
|
||||||
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
|
"${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)"
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch source manifest (HTTP $HM_LAST_HTTP_CODE)"
|
||||||
|
|
||||||
local content_type
|
local content_type
|
||||||
@@ -1208,6 +1220,8 @@ EOF
|
|||||||
|
|
||||||
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \
|
manifest_body=$(raw_http GET "${src_registry}/v2/${src_image}/manifests/${plat_digest}" \
|
||||||
"${src_auth[@]}" "${MANIFEST_ACCEPT_HEADERS[@]}")
|
"${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)"
|
[[ "$HM_LAST_HTTP_CODE" == "200" ]] || die "Failed to fetch platform manifest (HTTP $HM_LAST_HTTP_CODE)"
|
||||||
content_type=$(get_response_header "content-type") || true
|
content_type=$(get_response_header "content-type") || true
|
||||||
fi
|
fi
|
||||||
@@ -1587,8 +1601,8 @@ parse_global_args() {
|
|||||||
-h|--help) show_usage; exit 0 ;;
|
-h|--help) show_usage; exit 0 ;;
|
||||||
--version) cmd_version; exit 0 ;;
|
--version) cmd_version; exit 0 ;;
|
||||||
--) shift; remaining+=("$@"); break ;;
|
--) shift; remaining+=("$@"); break ;;
|
||||||
# Stop global parsing at first non-flag arg or unknown flag
|
# Pass non-global args through, but keep parsing remaining args
|
||||||
*) remaining+=("$@"); break ;;
|
*) remaining+=("$1"); shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user