Credential Helper Rewrite#
Context#
The current credential helper (cmd/credential-helper/main.go, ~1070 lines) is a monolithic single-file binary with a manual switch dispatch. It has no help text, hangs silently when run without stdin, embeds interactive device auth inside the Docker protocol get command (blocking pushes for up to 2 minutes while polling), and only supports one account per registry. Users want multi-account support (e.g., evan.jarrett.net and michelle.jarrett.net on the same atcr.io) and multi-registry support (e.g., atcr.io + buoy.cr).
Approach#
Rewrite using Cobra (already a project dependency) for the CLI framework and charmbracelet/huh for interactive prompts (select menus, confirmations, spinners). Separate Docker protocol commands (machine-readable, hidden) from user-facing commands (interactive, discoverable). Model after gh auth UX patterns.
Smart account auto-detection: The get command inspects the parent process command line (/proc/<ppid>/cmdline on Linux, ps on macOS) to determine which image Docker is pushing/pulling. Since ATCR URLs are host/<identity>/repo:tag, we can extract the identity and auto-select the matching account — no prompts, no manual switching needed in the common case.
Command Tree#
docker-credential-atcr
├── get (Docker protocol — stdin/stdout, hidden, smart account detection)
├── store (Docker protocol — stdin, hidden)
├── erase (Docker protocol — stdin, hidden)
├── list (Docker protocol extension, hidden)
├── login (Interactive device flow with huh prompts)
├── logout (Remove account credentials)
├── status (Show all accounts with active indicators)
├── switch (Switch active account — auto-toggle for 2, select for 3+)
├── configure-docker (Auto-edit ~/.docker/config.json credHelpers)
├── update (Self-update, existing logic preserved)
└── version (Built-in via cobra)
Smart Account Resolution (get command)#
The get command resolves which account to use with this priority chain — fully non-interactive:
1. Parse parent process cmdline → extract identity from image ref
docker push atcr.io/evan.jarrett.net/test:latest
→ parent cmdline contains "evan.jarrett.net" → use that account
2. Fall back to active account (set by `switch` command)
3. Fall back to sole account (if only one exists for this registry)
4. Error with helpful message:
"Multiple accounts for atcr.io. Run: docker-credential-atcr switch"
Parent process detection (in helpers.go):
- Linux: read
/proc/<ppid>/cmdline(null-separated args) - macOS:
ps -o args= -p <ppid> - Windows: best-effort via
wmicor skip (fall to active account) - Parse image ref: find the arg matching
<registry-host>/<identity>/..., extract<identity> - Graceful failure: if parent isn't Docker, cmdline unreadable, or image ref not parseable → fall through to active account
File Structure#
cmd/credential-helper/
main.go — Cobra root command, version vars, subcommand registration
config.go — Config types, load/save/migrate, getConfigPath
device_auth.go — authorizeDevice(), validateCredentials() HTTP logic
protocol.go — Docker protocol: get, store, erase, list (all hidden)
cmd_login.go — login command (huh prompts + device flow)
cmd_logout.go — logout command (huh confirm)
cmd_status.go — status display
cmd_switch.go — switch command (huh select)
cmd_configure.go — configure-docker (edit ~/.docker/config.json)
cmd_update.go — update command (moved from existing code)
helpers.go — openBrowser, buildAppViewURL, isInsecureRegistry, parentCmdline, etc.
Config Format (~/.atcr/device.json)#
{
"version": 2,
"registries": {
"https://atcr.io": {
"active": "evan.jarrett.net",
"accounts": {
"evan.jarrett.net": {
"handle": "evan.jarrett.net",
"did": "did:plc:abc123",
"device_secret": "atcr_device_..."
},
"michelle.jarrett.net": {
"handle": "michelle.jarrett.net",
"did": "did:plc:def456",
"device_secret": "atcr_device_..."
}
}
},
"https://buoy.cr": {
"active": "evan.jarrett.net",
"accounts": { ... }
}
}
}
Migration: loadConfig() auto-detects and migrates from old formats:
- Legacy single-device
{handle, device_secret, appview_url}→ v2 - Current multi-registry
{credentials: {url: {...}}}→ v2 - Writes back migrated config on first load
Key Behavioral Changes#
| Command | Current | New |
|---|---|---|
get |
Opens browser, polls 2min if no creds | Smart detection → active account → error |
get (multi-account) |
N/A (single account only) | Auto-detects identity from parent cmdline |
get (no stdin) |
Hangs forever | Detects terminal, prints help, exits 1 |
get (OAuth expired) |
Auto-opens browser, polls | Prints login URL, exits 1 |
store |
No-op | Stores if secret is device secret (atcr_device_*) |
erase |
Removes all creds for host | Removes active account only |
| No args | Prints bare usage | Prints full cobra help with all commands |
Dependencies#
github.com/spf13/cobra— already in go.modgithub.com/charmbracelet/huh— new (pure Go, CGO_ENABLED=0 safe)
No changes to .goreleaser.yaml needed.
Implementation Order#
Phase 1: Foundation#
helpers.go— move utility functions verbatim + addgetParentCmdline()anddetectIdentityFromParent(registryHost)config.go— new config types + migration from old formatsmain.go— Cobra root command, register all subcommands
Phase 2: Docker Protocol (must work for existing users)#
device_auth.go— extractauthorizeDevice()+validateCredentials()protocol.go—get/store/erase/listusing new config with smart account resolution
Phase 3: User Commands#
cmd_login.go— interactive device flow with huh spinnercmd_status.go— display all registries/accountscmd_switch.go— huh select for account switchingcmd_logout.go— huh confirm for removalcmd_configure.go— Docker config.json manipulationcmd_update.go— move existing update logic
Phase 4: Polish#
- Add
huhto go.mod - Delete old
main.gocontents (replaced by new files)
What to Keep vs Rewrite#
Keep (move to new files): openBrowser(), buildAppViewURL(), isInsecureRegistry(), getDockerInsecureRegistries(), readDockerDaemonConfig(), stripPort(), isTerminal(), authorizeDevice() HTTP logic, validateCredentials(), all update/version check functions.
Rewrite: main(), handleGet() (split into non-interactive get with smart detection + interactive login), handleStore() (implement actual storage), handleErase() (multi-account aware), config types and loading.
New: list, login, logout, status, switch, configure-docker commands. Config migration. Parent process identity detection. huh integration.
Verification#
- Build:
go build -o bin/docker-credential-atcr ./cmd/credential-helper - Help works:
bin/docker-credential-atcr --helpshows all user commands - Protocol works:
echo "atcr.io" | bin/docker-credential-atcr getreturns credentials or helpful error - No hang:
bin/docker-credential-atcr get(no stdin pipe) detects terminal, prints help, exits - Smart detection:
docker push atcr.io/evan.jarrett.net/test:latestauto-selectsevan.jarrett.net - Login flow:
bin/docker-credential-atcr logintriggers device auth with huh prompts - Status:
bin/docker-credential-atcr statusshows configured accounts - Config migration: Place old-format
~/.atcr/device.json, run any command, verify auto-migration - GoReleaser:
CGO_ENABLED=0 go build ./cmd/credential-helpersucceeds