You've already forked hubmanager
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e70596cd59 |
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)
|
||||
|
||||
### 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
|
||||
|
||||
31
README.md
31
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> [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
|
||||
|
||||
36
hubmanager
36
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 <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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user