3 Commits
v0.1.0 ... dev

Author SHA1 Message Date
242eeca238 feat: add cross-invocation passphrase caching via Linux keyring (v0.3.0)
Use keyctl (keyutils) to cache the master passphrase in the kernel keyring
with a configurable TTL (default 5 min). New unlock/lock subcommands for
manual cache control. keyctl is optional — silently skipped if not installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 05:04:07 +01:00
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
a59e416789 feat: add AES-256-CBC encrypted password storage (v0.2.0)
Add `login --save --encrypt` flag: passwords are encrypted with
openssl AES-256-CBC (PBKDF2) and stored as `enc:<base64>` in the
config file. A master passphrase is prompted once per session and
cached in memory. Both load_config() and resolve_registry_alias()
detect the enc: prefix and decrypt transparently. The passphrase is
passed to openssl via a temp file to avoid argv/env exposure.
openssl is an optional dependency, checked on demand.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:52:48 +01:00
3 changed files with 510 additions and 41 deletions

148
CLAUDE.md
View File

@@ -10,23 +10,142 @@ authentication detection (bearer token or HTTP basic auth).
--- ---
## What Changed (Session 2026-03-01b)
### Passphrase caching via Linux keyring (v0.3.0)
Added cross-invocation master passphrase caching using the Linux kernel keyring
(`keyctl` from `keyutils`). `keyctl` is an **optional** dependency — if not
installed, behaviour is unchanged (prompt every time).
**New functions** (Encryption helpers section):
| Function | Purpose |
| --- | --- |
| `_keyring_available()` | Returns 0 if `keyctl` is installed |
| `_keyring_get()` | Read cached passphrase from user keyring; return 1 if missing/expired |
| `_keyring_set(pass, ttl)` | Store passphrase in keyring with TTL |
| `_keyring_clear()` | Revoke the cached key |
**New subcommands**:
| Command | Purpose |
| --- | --- |
| `unlock` | Prompt for passphrase and cache in keyring (useful before scripting) |
| `lock` | Clear cached passphrase from keyring immediately |
**New global option**: `--cache-timeout <seconds>` (default 300 / 5 min).
Also configurable via `CACHE_TIMEOUT=<seconds>` in `hubmanager.conf`.
**New constants**: `HM_KEYRING_KEY`, `HM_KEYRING_DEFAULT_TIMEOUT`.
**New global variable**: `HM_CACHE_TIMEOUT`.
**Modified functions**:
- `_prompt_master_pass()` — checks keyring before prompting; stores after prompt
- `_prompt_set_master_pass()` — stores in keyring after confirmation
**Version bump**: `0.2.1``0.3.0`
---
## 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.:
```txt
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.0``0.2.0`
---
## What Was Built (Session 2025-02-21) ## What Was Built (Session 2025-02-21)
### Primary file ### Primary file
`hubmanager` — executable Bash script, ~600 lines, no dependencies beyond `curl`, `jq`, Bash 4+. `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 ### Subcommands implemented
| 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 |
| `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` | | `delete` | Resolve tag → digest → `DELETE`; requires confirmation or `--yes` |
| `copy` | Copy/retag within or across registries; blob mount for same-registry retag | | `copy` | Copy/retag within or across registries; blob mount for same-registry retag |
| `prune` | Delete outdated tags sorted by image creation date | | `prune` | Delete outdated tags sorted by image creation date |
| `unlock` | Cache master passphrase in kernel keyring (requires `keyctl`) |
| `lock` | Clear cached master passphrase from keyring |
### Supporting files ### Supporting files
@@ -47,11 +166,13 @@ set -euo pipefail
# --- Global state --- # --- Global state ---
# --- Output / Formatting helpers --- # --- Output / Formatting helpers ---
# --- Dependency check --- # --- Dependency check ---
# --- Encryption helpers --- _encrypt_value(), _decrypt_value(), _prompt_master_pass()
# _keyring_get(), _keyring_set(), _keyring_clear()
# --- Config loading --- # --- Config loading ---
# --- HTTP helpers --- raw_http(), get_response_header() # --- HTTP helpers --- raw_http(), get_response_header()
# --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header() # --- Authentication --- probe_registry_auth(), get_bearer_token(), make_auth_header()
# --- Registry request --- registry_request() ← main HTTP wrapper # --- Registry request --- registry_request() ← main HTTP wrapper
# --- Subcommands --- cmd_login/list/tags/inspect/delete/copy/prune # --- Subcommands --- cmd_login/unlock/lock/list/tags/inspect/delete/copy/prune
# --- Global arg parsing --- parse_global_args() # --- Global arg parsing --- parse_global_args()
# --- Main dispatcher --- main() # --- Main dispatcher --- main()
main "$@" main "$@"
@@ -82,7 +203,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
@@ -96,6 +217,23 @@ main "$@"
- Blob already at destination (`HEAD` returns 200) → skip - Blob already at destination (`HEAD` returns 200) → skip
- Otherwise → download to temp file → `POST` initiate upload → `PUT` with digest - 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.
10. **Passphrase caching** (v0.3.0):
- Uses Linux kernel keyring (`keyctl` from `keyutils`) to cache the master passphrase
across invocations with a configurable TTL (default 300s / 5 min).
- `_prompt_master_pass()` checks keyring before prompting; stores after prompt.
- `keyctl` is optional: if not installed, each invocation prompts as before (no errors).
- `unlock` pre-caches the passphrase; `lock` clears it immediately.
- TTL configurable via `--cache-timeout <seconds>` flag or `CACHE_TIMEOUT` config key.
--- ---
## Global Variables (key ones) ## Global Variables (key ones)
@@ -110,6 +248,8 @@ main "$@"
| `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL | | `HM_AUTH_REALM["registry"]` | Bearer token endpoint URL |
| `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token | | `HM_TOKEN_CACHE["registry\|scope"]` | Cached bearer token |
| `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) | | `HM_TOKEN_EXPIRY["registry\|scope"]` | Token expiry (epoch seconds) |
| `HM_MASTER_PASS` | Master passphrase for `enc:` config values (session-cached) |
| `HM_CACHE_TIMEOUT` | Keyring cache TTL in seconds (default 300) |
| `HM_LAST_HTTP_CODE` | HTTP status of most recent request | | `HM_LAST_HTTP_CODE` | HTTP status of most recent request |
| `HM_LAST_HEADERS_FILE` | Temp file path with response headers | | `HM_LAST_HEADERS_FILE` | Temp file path with response headers |
| `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` | | `HM_TMPFILES` | Array of temp files, cleaned up via `trap EXIT` |

164
README.md
View File

@@ -7,6 +7,8 @@ A Bash CLI tool to manage Docker Registry images remotely. Supports Docker Hub a
- **Bash** 4.0+ - **Bash** 4.0+
- **curl** - **curl**
- **jq** - **jq**
- **openssl** *(optional — required only when using encrypted config values)*
- **keyctl** *(optional — from `keyutils`; enables passphrase caching across invocations)*
## Installation ## Installation
@@ -25,6 +27,10 @@ install -m 755 hubmanager ~/bin/hubmanager
hubmanager login --registry https://registry.example.com \ hubmanager login --registry https://registry.example.com \
--user admin --password secret --save --user admin --password secret --save
# Save with password encrypted at rest
hubmanager login --registry https://registry.example.com \
--user admin --password secret --save --encrypt
# List all repositories # List all repositories
hubmanager list hubmanager list
@@ -43,12 +49,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
@@ -74,16 +80,72 @@ hubmanager --registry staging list
hubmanager --registry hub tags myuser/myapp hubmanager --registry hub tags myuser/myapp
``` ```
### Encrypted credentials
Use `login --save --encrypt` to store the password encrypted with AES-256-CBC.
A master passphrase is prompted at save time (with confirmation) and on every subsequent
invocation that reads the config file. Requires `openssl`.
```bash
hubmanager login --registry https://registry.example.com \
--user admin --password secret --save --encrypt
# New master passphrase: ****
# Confirm master passphrase: ****
# Login Succeeded — bearer auth, registry: https://registry.example.com
# Credentials saved to /home/user/.config/hubmanager.conf
# Password stored encrypted (AES-256-CBC). Master passphrase required on each use.
```
The config file stores an `enc:` prefixed ciphertext:
```text
REGISTRY=https://registry.example.com
USERNAME=admin
PASSWORD=enc:U2FsdGVkX1+...base64ciphertext...
```
The `enc:` prefix also works for named alias passwords (`REGISTRY_<ALIAS>_PASSWORD`).
On every command that reads the config, the master passphrase is prompted once and cached
for the duration of the session.
### Passphrase caching across invocations
If `keyctl` (from the `keyutils` package) is installed, the master passphrase is
automatically cached in the Linux kernel keyring for 5 minutes. Subsequent commands
within that window will not re-prompt.
```bash
# First command prompts for passphrase, caches it for 5 min
hubmanager list
# Runs without prompting (within cache window)
hubmanager tags myuser/myapp
# Pre-cache before a scripted batch
hubmanager unlock
hubmanager list && hubmanager tags myuser/myapp && hubmanager inspect myuser/myapp:latest
# Clear cache immediately
hubmanager lock
# Custom timeout (10 minutes)
hubmanager unlock --cache-timeout 600
```
If `keyctl` is not installed, passphrase caching is silently skipped — each invocation
prompts as before.
## Global Options ## Global Options
``` ```text
hubmanager [OPTIONS] <command> [COMMAND OPTIONS] hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
-r, --registry <url> Registry base URL -r, --registry <url> Registry base URL
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)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--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)
@@ -98,24 +160,61 @@ hubmanager [OPTIONS] <command> [COMMAND OPTIONS]
### `login` — Test and save credentials ### `login` — Test and save credentials
``` ```text
hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt]
``` ```
Validates credentials against the registry. Use `--save` to write them to the config file. Validates credentials against the registry. Use `--save` to write them to the config file.
Add `--encrypt` to store the password encrypted with AES-256-CBC (requires `openssl`).
```bash ```bash
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
```
---
### `unlock` — Cache master passphrase
```text
hubmanager unlock [--cache-timeout SECONDS]
```
Prompts for the master passphrase and stores it in the Linux kernel keyring
for the configured timeout (default: 300 seconds / 5 minutes). Useful before
running a batch of commands. Requires `keyctl` (keyutils package).
```bash
hubmanager unlock
# hubmanager master passphrase: ****
# Passphrase cached for 300s.
# Custom timeout
hubmanager unlock --cache-timeout 600
```
---
### `lock` — Clear cached passphrase
```text
hubmanager lock
```
Immediately revokes the cached passphrase from the kernel keyring.
```bash
hubmanager lock
# Passphrase cache cleared.
``` ```
--- ---
### `list` — List repositories ### `list` — List repositories
``` ```text
hubmanager list [--limit N] [--last REPO] hubmanager list [--limit N] [--last REPO]
``` ```
@@ -139,7 +238,7 @@ hubmanager list --json | jq '.repositories[]'
### `tags` — List tags for an image ### `tags` — List tags for an image
``` ```text
hubmanager tags <image> [--limit N] [--last TAG] hubmanager tags <image> [--limit N] [--last TAG]
``` ```
@@ -162,7 +261,7 @@ hubmanager tags myuser/myapp --json | jq '.tags[]'
### `inspect` — Show image details ### `inspect` — Show image details
``` ```text
hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH] hubmanager inspect <image>:<tag|digest> [--platform OS/ARCH]
``` ```
@@ -201,7 +300,7 @@ hubmanager inspect myuser/myapp:latest --json | jq .
### `delete` — Delete a tag or manifest ### `delete` — Delete a tag or manifest
``` ```text
hubmanager delete <image>:<tag|digest> [--yes] hubmanager delete <image>:<tag|digest> [--yes]
``` ```
@@ -225,9 +324,11 @@ hubmanager delete myuser/myapp@sha256:abc123... --yes
### `copy` — Copy or retag an image ### `copy` — Copy or retag an image
``` ```text
hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options] hubmanager copy <src-image>:<tag> <dst-image>:<tag> [options]
```
```text
Options: Options:
--src-registry URL Source registry (default: global --registry) --src-registry URL Source registry (default: global --registry)
--dst-registry URL Destination registry (default: global --registry) --dst-registry URL Destination registry (default: global --registry)
@@ -239,11 +340,13 @@ Options:
``` ```
**Same-registry retag** — attempts cross-repo blob mount (no data transfer): **Same-registry retag** — attempts cross-repo blob mount (no data transfer):
```bash ```bash
hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable hubmanager copy myuser/myapp:v1.2.3 myuser/myapp:stable
``` ```
**Cross-registry copy** — streams blobs from source to destination: **Cross-registry copy** — streams blobs from source to destination:
```bash ```bash
hubmanager copy myuser/myapp:latest \ hubmanager copy myuser/myapp:latest \
--src-registry https://registry-1.docker.io \ --src-registry https://registry-1.docker.io \
@@ -252,6 +355,7 @@ hubmanager copy myuser/myapp:latest \
``` ```
**Copy specific platform** from a multi-arch image: **Copy specific platform** from a multi-arch image:
```bash ```bash
hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64 hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
``` ```
@@ -260,7 +364,7 @@ hubmanager copy nginx:latest myuser/nginx-amd64:latest --platform linux/amd64
### `prune` — Delete outdated tags ### `prune` — Delete outdated tags
``` ```text
hubmanager prune <image> [options] hubmanager prune <image> [options]
Options: Options:
@@ -300,7 +404,7 @@ hubmanager prune myuser/myapp --keep 1 --no-exclude --dry-run
hubmanager automatically detects the authentication method by probing the registry's `/v2/` endpoint: hubmanager automatically detects the authentication method by probing the registry's `/v2/` endpoint:
| Registry type | Auth method | | Registry type | Auth method |
|---|---| | --- | --- |
| Docker Hub | Bearer tokens via `auth.docker.io` | | Docker Hub | Bearer tokens via `auth.docker.io` |
| Harbor, self-hosted with token server | Bearer tokens via registry-configured realm | | Harbor, self-hosted with token server | Bearer tokens via registry-configured realm |
| Basic-auth self-hosted | HTTP Basic Auth on every request | | Basic-auth self-hosted | HTTP Basic Auth on every request |
@@ -311,9 +415,28 @@ Bearer tokens are cached in memory for the duration of the session and refreshed
### Docker Hub notes ### Docker Hub notes
- `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub. - `list` uses the Docker Hub REST API (`hub.docker.com`) because the `_catalog` endpoint is restricted on Docker Hub.
- `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
@@ -341,7 +464,7 @@ hubmanager prune myapp --keep 3 --yes --json
## Exit Codes ## Exit Codes
| Code | Meaning | | Code | Meaning |
|------|---------| | --- | --- |
| 0 | Success | | 0 | Success |
| 1 | General / usage error | | 1 | General / usage error |
| 2 | Authentication failure | | 2 | Authentication failure |
@@ -380,12 +503,13 @@ hubmanager copy myuser/myapp:staging myuser/myapp:production
## Configuration reference ## Configuration reference
| Key | Description | | Key | Description |
|-----|-------------| | --- | --- |
| `REGISTRY` | Default registry URL | | `REGISTRY` | Default registry URL |
| `USERNAME` | Default username | | `USERNAME` | Default username |
| `PASSWORD` | Default password or token | | `PASSWORD` | Default password or token; prefix with `enc:` for encrypted values |
| `CACHE_TIMEOUT` | Passphrase keyring cache TTL in seconds (default: 300) |
| `REGISTRY_<ALIAS>_URL` | URL for a named registry alias | | `REGISTRY_<ALIAS>_URL` | URL for a named registry alias |
| `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias | | `REGISTRY_<ALIAS>_USERNAME` | Username for a named alias |
| `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias | | `REGISTRY_<ALIAS>_PASSWORD` | Password for a named alias (supports `enc:` prefix) |
Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`. Aliases are case-insensitive and treat `-` as `_`. For example, alias `my-reg` maps to `REGISTRY_MY_REG_URL`.

View File

@@ -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.1.0 # Version: 0.3.0
# Dependencies: curl, jq, bash 4+ # Dependencies: curl, jq, bash 4+
# Usage: hubmanager --help # Usage: hubmanager --help
@@ -9,10 +9,12 @@ set -euo pipefail
# ============================================================================= # =============================================================================
# --- Constants --- # --- Constants ---
# ============================================================================= # =============================================================================
readonly HM_VERSION="0.1.0" readonly HM_VERSION="0.3.0"
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"
readonly HM_KEYRING_KEY="hubmanager:master"
readonly HM_KEYRING_DEFAULT_TIMEOUT=300 # seconds (5 minutes)
# ============================================================================= # =============================================================================
# --- Global state --- # --- Global state ---
@@ -34,12 +36,16 @@ declare -A HM_TOKEN_EXPIRY=() # "registry|scope" -> epoch seconds
declare -A HM_CONFIG_VARS=() # raw config key/value pairs declare -A HM_CONFIG_VARS=() # raw config key/value pairs
HM_HUB_TOKEN="" # Docker Hub REST API token (JWT) HM_HUB_TOKEN="" # Docker Hub REST API token (JWT)
HM_MASTER_PASS="" # Master passphrase for encrypted config values (session-scoped)
HM_CACHE_TIMEOUT="$HM_KEYRING_DEFAULT_TIMEOUT" # Keyring cache TTL in seconds
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 ---
@@ -98,6 +104,107 @@ check_deps() {
(( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)." (( BASH_VERSINFO[0] >= 4 )) || die "Bash 4.0+ is required (found: $BASH_VERSION)."
} }
# =============================================================================
# --- Encryption helpers ---
# =============================================================================
# Verify openssl is available (only needed when enc: values are present)
_require_openssl() {
command -v openssl &>/dev/null || \
die "openssl is required for encrypted config values. Install openssl and retry."
}
# --- Keyring helpers (optional; keyctl from keyutils) ---
# Check if keyctl is available
_keyring_available() { command -v keyctl &>/dev/null; }
# Try to read the cached passphrase from the user session keyring.
# Outputs the passphrase on stdout; returns 1 if not found or expired.
_keyring_get() {
_keyring_available || return 1
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 1
keyctl pipe "$key_id" 2>/dev/null || return 1
}
# Store passphrase in the user session keyring with a TTL.
# Args: <passphrase> [timeout_seconds]
_keyring_set() {
_keyring_available || return 0
local pass="$1"
local ttl="${2:-$HM_CACHE_TIMEOUT}"
local key_id
key_id=$(keyctl add user "$HM_KEYRING_KEY" "$pass" @u 2>/dev/null) || return 0
keyctl timeout "$key_id" "$ttl" 2>/dev/null || true
}
# Revoke (clear) the cached passphrase from the keyring.
_keyring_clear() {
_keyring_available || return 0
local key_id
key_id=$(keyctl request user "$HM_KEYRING_KEY" 2>/dev/null) || return 0
keyctl revoke "$key_id" 2>/dev/null || true
}
# Prompt once per session; result cached in HM_MASTER_PASS and keyring
_prompt_master_pass() {
[[ -n "$HM_MASTER_PASS" ]] && return 0
# Try keyring cache first
local cached
if cached=$(_keyring_get); then
HM_MASTER_PASS="$cached"
return 0
fi
_require_openssl
printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
}
# Prompt and confirm a new master passphrase (used by login --encrypt)
_prompt_set_master_pass() {
_require_openssl
local pass1 pass2
printf "New master passphrase: " >/dev/tty
read -rs pass1 </dev/tty; printf "\n" >/dev/tty
printf "Confirm master passphrase: " >/dev/tty
read -rs pass2 </dev/tty; printf "\n" >/dev/tty
[[ "$pass1" == "$pass2" ]] || die "Passphrases do not match."
[[ -n "$pass1" ]] || die "Master passphrase cannot be empty."
HM_MASTER_PASS="$pass1"
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
}
# Encrypt PLAINTEXT using AES-256-CBC; outputs base64 ciphertext (no newlines)
_encrypt_value() {
local plaintext="$1"
_prompt_master_pass
local pass_file result
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
result=$(printf '%s' "$plaintext" | \
openssl enc -aes-256-cbc -pbkdf2 -a -pass "file:${pass_file}" 2>/dev/null | tr -d '\n')
[[ -n "$result" ]] || die "Encryption failed. Check that openssl is installed."
printf '%s' "$result"
}
# Decrypt base64 CIPHERTEXT produced by _encrypt_value; outputs plaintext
_decrypt_value() {
local ciphertext="$1"
_prompt_master_pass
local pass_file result
pass_file=$(mktemp); HM_TMPFILES+=("$pass_file")
printf '%s' "$HM_MASTER_PASS" > "$pass_file"
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
printf '%s' "$result"
}
# ============================================================================= # =============================================================================
# --- Config loading --- # --- Config loading ---
# ============================================================================= # =============================================================================
@@ -127,8 +234,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:]]}"}"
@@ -138,7 +244,18 @@ load_config() {
# Apply config defaults (CLI flags already set take precedence) # Apply config defaults (CLI flags already set take precedence)
[[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}" [[ -z "$HM_REGISTRY" && -v 'HM_CONFIG_VARS[REGISTRY]' ]] && HM_REGISTRY="${HM_CONFIG_VARS[REGISTRY]}"
[[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}" [[ -z "$HM_USERNAME" && -v 'HM_CONFIG_VARS[USERNAME]' ]] && HM_USERNAME="${HM_CONFIG_VARS[USERNAME]}"
[[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]] && HM_PASSWORD="${HM_CONFIG_VARS[PASSWORD]}" if [[ -z "$HM_PASSWORD" && -v 'HM_CONFIG_VARS[PASSWORD]' ]]; then
local pw="${HM_CONFIG_VARS[PASSWORD]}"
if [[ "$pw" == enc:* ]]; then
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
else
HM_PASSWORD="$pw"
fi
fi
# Cache timeout (only override if not set via CLI)
if [[ "$HM_CACHE_TIMEOUT" == "$HM_KEYRING_DEFAULT_TIMEOUT" && -v 'HM_CONFIG_VARS[CACHE_TIMEOUT]' ]]; then
HM_CACHE_TIMEOUT="${HM_CONFIG_VARS[CACHE_TIMEOUT]}"
fi
} }
resolve_registry_alias() { resolve_registry_alias() {
@@ -153,7 +270,14 @@ resolve_registry_alias() {
local pass_key="REGISTRY_${alias_upper}_PASSWORD" local pass_key="REGISTRY_${alias_upper}_PASSWORD"
HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}" HM_REGISTRY="${HM_CONFIG_VARS[$url_key]}"
[[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}" [[ -z "$HM_USERNAME" && -v "HM_CONFIG_VARS[$user_key]" ]] && HM_USERNAME="${HM_CONFIG_VARS[$user_key]}"
[[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]] && HM_PASSWORD="${HM_CONFIG_VARS[$pass_key]}" if [[ -z "$HM_PASSWORD" && -v "HM_CONFIG_VARS[$pass_key]" ]]; then
local pw="${HM_CONFIG_VARS[$pass_key]}"
if [[ "$pw" == enc:* ]]; then
HM_PASSWORD=$(_decrypt_value "${pw#enc:}")
else
HM_PASSWORD="$pw"
fi
fi
fi fi
fi fi
# Strip trailing slash # Strip trailing slash
@@ -200,6 +324,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"
@@ -394,6 +522,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
@@ -414,6 +544,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
@@ -522,7 +654,8 @@ ${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)
--cache-timeout <s> Passphrase cache TTL in seconds (default: 300)
--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
@@ -537,7 +670,9 @@ ${C_BOLD}Commands:${C_RESET}
delete <image>:<ref> Delete an image tag or manifest delete <image>:<ref> Delete an image tag or manifest
copy <src> <dst> Copy/retag an image copy <src> <dst> Copy/retag an image
prune <image> Delete outdated tags for an image prune <image> Delete outdated tags for an image
login Test credentials and optionally save to config login Test credentials and optionally save to config (--save [--encrypt])
unlock Cache master passphrase in kernel keyring (requires keyctl)
lock Clear cached master passphrase
Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options. Run ${C_BOLD}hubmanager <command> --help${C_RESET} for command-specific options.
EOF EOF
@@ -550,23 +685,73 @@ cmd_help() { show_usage; }
# ============================================================================= # =============================================================================
cmd_version() { echo "hubmanager $HM_VERSION"; } cmd_version() { echo "hubmanager $HM_VERSION"; }
# =============================================================================
# --- Subcommand: unlock ---
# =============================================================================
cmd_unlock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager unlock [--timeout <seconds>]
Cache the master passphrase in the Linux kernel keyring so subsequent
commands do not re-prompt. Requires ${C_BOLD}keyctl${C_RESET} (keyutils package).
--timeout <sec> Cache duration (default: ${HM_KEYRING_DEFAULT_TIMEOUT}s / 5 min)
EOF
return 0
fi
_keyring_available || die "keyctl (keyutils) is required for passphrase caching. Install keyutils and retry."
_require_openssl
# Force a fresh prompt even if already cached
HM_MASTER_PASS=""
printf "hubmanager master passphrase: " >/dev/tty
read -rs HM_MASTER_PASS </dev/tty
printf "\n" >/dev/tty
[[ -n "$HM_MASTER_PASS" ]] || die "Master passphrase cannot be empty."
_keyring_set "$HM_MASTER_PASS" "$HM_CACHE_TIMEOUT"
info "Passphrase cached for ${HM_CACHE_TIMEOUT}s."
}
# =============================================================================
# --- Subcommand: lock ---
# =============================================================================
cmd_lock() {
if [[ "${1:-}" == "--help" ]]; then
cat <<EOF
${C_BOLD}Usage:${C_RESET} hubmanager lock
Clear the cached master passphrase from the Linux kernel keyring.
EOF
return 0
fi
_keyring_clear
HM_MASTER_PASS=""
info "Passphrase cache cleared."
}
# ============================================================================= # =============================================================================
# --- Subcommand: login --- # --- Subcommand: login ---
# ============================================================================= # =============================================================================
cmd_login() { cmd_login() {
local do_save=false local do_save=false
local do_encrypt=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--save) do_save=true; shift ;; --save) do_save=true; shift ;;
--encrypt) do_encrypt=true; shift ;;
-h|--help) -h|--help)
cat <<'EOF' cat <<'EOF'
Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] Usage: hubmanager login [--registry URL] [--user USER] [--password PASS] [--save] [--encrypt]
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.
A master passphrase will be prompted; you must enter the same
passphrase on every subsequent hubmanager invocation that reads
the config file.
EOF EOF
exit 0 ;; exit 0 ;;
*) die "Unknown option: $1. Run 'hubmanager login --help'." ;; *) die "Unknown option: $1. Run 'hubmanager login --help'." ;;
@@ -601,16 +786,29 @@ 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=""
if [[ -n "$HM_PASSWORD" ]]; then
if [[ "$do_encrypt" == true ]]; then
_prompt_set_master_pass
local ciphertext
ciphertext=$(_encrypt_value "$HM_PASSWORD")
pw_entry="PASSWORD=enc:${ciphertext}"
else
pw_entry="PASSWORD=${HM_PASSWORD}"
fi
fi
{ {
echo "# hubmanager configuration" echo "# hubmanager configuration"
echo "# Generated by: hubmanager login" echo "# Generated by: hubmanager login"
echo "" echo ""
echo "REGISTRY=${HM_REGISTRY}" echo "REGISTRY=${HM_REGISTRY}"
[[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}" [[ -n "$HM_USERNAME" ]] && echo "USERNAME=${HM_USERNAME}"
[[ -n "$HM_PASSWORD" ]] && echo "PASSWORD=${HM_PASSWORD}" [[ -n "$pw_entry" ]] && echo "$pw_entry"
} > "$config" } > "$config"
chmod 600 "$config" chmod 600 "$config"
info "Credentials saved to $config" info "Credentials saved to $config"
[[ "$do_encrypt" == true ]] && info "Password stored encrypted (AES-256-CBC). Master passphrase required on each use."
fi fi
} }
@@ -1094,6 +1292,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
@@ -1115,6 +1315,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
@@ -1486,16 +1688,17 @@ parse_global_args() {
-r|--registry) HM_REGISTRY="$2"; shift 2 ;; -r|--registry) HM_REGISTRY="$2"; shift 2 ;;
-u|--user) HM_USERNAME="$2"; shift 2 ;; -u|--user) HM_USERNAME="$2"; shift 2 ;;
-p|--password) HM_PASSWORD="$2"; shift 2 ;; -p|--password) HM_PASSWORD="$2"; shift 2 ;;
--config) HM_CONFIG_FILE="$2"; shift 2 ;; --config) HM_CONFIG_FILE="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;; --cache-timeout) HM_CACHE_TIMEOUT="$2"; shift 2 ;;
--json) HM_OPT_JSON=true; shift ;;
--no-color) HM_OPT_NO_COLOR=true; shift ;; --no-color) HM_OPT_NO_COLOR=true; shift ;;
-v|--verbose) HM_OPT_VERBOSE=true; shift ;; -v|--verbose) HM_OPT_VERBOSE=true; shift ;;
-q|--quiet) HM_OPT_QUIET=true; shift ;; -q|--quiet) HM_OPT_QUIET=true; shift ;;
-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
@@ -1528,6 +1731,8 @@ main() {
copy) cmd_copy "$@" ;; copy) cmd_copy "$@" ;;
prune) cmd_prune "$@" ;; prune) cmd_prune "$@" ;;
login) cmd_login "$@" ;; login) cmd_login "$@" ;;
unlock) cmd_unlock "$@" ;;
lock) cmd_lock "$@" ;;
help|-h|--help) cmd_help ;; help|-h|--help) cmd_help ;;
version|--version) cmd_version ;; version|--version) cmd_version ;;
*) *)