diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a43785a..dc50cb2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/goreleaser/goreleaser/refs/heads/main/www/static/schema.json # GoReleaser configuration for ATCR # See https://goreleaser.com for documentation @@ -12,7 +13,8 @@ builds: # Credential helper - cross-platform native binary distribution - id: credential-helper binary: docker-credential-atcr - main: ./cmd/credential-helper + main: . + dir: ./cmd/credential-helper/atcr env: - CGO_ENABLED=0 goos: diff --git a/Makefile b/Makefile index 69cb846..f02526b 100644 --- a/Makefile +++ b/Makefile @@ -51,10 +51,10 @@ build-hold: $(GENERATED_ASSETS) ## Build hold binary only @mkdir -p bin go build -o bin/atcr-hold ./cmd/hold -build-credential-helper: ## Build credential helper only +build-credential-helper: ## Build credential helper only (atcr brand) @echo "→ Building credential helper..." @mkdir -p bin - go build -o bin/docker-credential-atcr ./cmd/credential-helper + cd cmd/credential-helper/atcr && go build -ldflags="-X main.version=$(shell git describe --tags --always 2>/dev/null || echo dev) -X main.commit=$(shell git rev-parse HEAD 2>/dev/null || echo none)" -o ../../../bin/docker-credential-atcr . build-oauth-helper: ## Build OAuth helper only @echo "→ Building OAuth helper..." @@ -84,7 +84,7 @@ build-trixie: $(GENERATED_ASSETS) ## Build all production binaries (appview, hol set -e && \ go build -trimpath -tags billing -ldflags="-s -w $(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview && \ go build -trimpath -ldflags="-s -w" -o bin/atcr-hold ./cmd/hold && \ - go build -trimpath -ldflags="-s -w" -o bin/docker-credential-atcr ./cmd/credential-helper && \ + (cd cmd/credential-helper/atcr && go build -trimpath -ldflags="-s -w" -o ../../../bin/docker-credential-atcr .) && \ go build -trimpath -ldflags="-s -w" -o bin/atcr-labeler ./cmd/labeler && \ cd scanner && go build -trimpath -ldflags="-s -w" -o ../bin/atcr-scanner ./cmd/scanner' @echo "✓ Built to bin/ (glibc ≥ 2.41 compatible)" diff --git a/cmd/credential-helper/atcr/go.mod b/cmd/credential-helper/atcr/go.mod new file mode 100644 index 0000000..f272a53 --- /dev/null +++ b/cmd/credential-helper/atcr/go.mod @@ -0,0 +1,10 @@ +module atcr.io/cmd/credential-helper/atcr + +go 1.26.2 + +// atcr.io is provided by the workspace during local development (go.work +// at the repo root). The require line below is what `go install +// atcr.io/cmd/credential-helper/atcr@latest` resolves against when the +// module is fetched standalone via the proxy. Bump it when cutting a +// new credhelper release. +require atcr.io v0.1.4 diff --git a/cmd/credential-helper/atcr/go.sum b/cmd/credential-helper/atcr/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/cmd/credential-helper/atcr/main.go b/cmd/credential-helper/atcr/main.go new file mode 100644 index 0000000..4f500dc --- /dev/null +++ b/cmd/credential-helper/atcr/main.go @@ -0,0 +1,28 @@ +// docker-credential-atcr is the Docker credential helper for atcr.io. +// +// Thin main that delegates to atcr.io/pkg/credhelper. The seamark.dev sibling +// at ../seamark mirrors this with brand-specific Config values. +package main + +import "atcr.io/pkg/credhelper" + +// Stamped at link time via -ldflags by the Makefile and goreleaser. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + credhelper.Run(credhelper.Config{ + BinaryName: "docker-credential-atcr", + DefaultRegistry: "atcr.io", + ConfigDirName: ".atcr", + SecretPrefix: "atcr_device_", + UpdateAssetName: "docker-credential-atcr", + ReleasesBaseURL: "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64", + Version: version, + Commit: commit, + Date: date, + }) +} diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go deleted file mode 100644 index 6afeb04..0000000 --- a/cmd/credential-helper/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/spf13/cobra" -) - -var ( - version = "dev" - commit = "none" - date = "unknown" - - // Update check cache TTL (24 hours) - updateCheckCacheTTL = 24 * time.Hour -) - -// timeNow is a variable so tests can override it. -var timeNow = time.Now - -func main() { - rootCmd := &cobra.Command{ - Use: "docker-credential-atcr", - Short: "ATCR container registry credential helper", - Long: `docker-credential-atcr manages authentication for ATCR-compatible container registries. - -It implements the Docker credential helper protocol and provides commands -for managing multiple accounts across multiple registries.`, - Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date), - SilenceUsage: true, - SilenceErrors: true, - } - - // Docker protocol commands (hidden — called by Docker, not users) - rootCmd.AddCommand(newGetCmd()) - rootCmd.AddCommand(newStoreCmd()) - rootCmd.AddCommand(newEraseCmd()) - rootCmd.AddCommand(newListCmd()) - - // User-facing commands - rootCmd.AddCommand(newLoginCmd()) - rootCmd.AddCommand(newLogoutCmd()) - rootCmd.AddCommand(newStatusCmd()) - rootCmd.AddCommand(newSwitchCmd()) - rootCmd.AddCommand(newConfigureDockerCmd()) - rootCmd.AddCommand(newUpdateCmd()) - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/credential-helper/seamark/go.mod b/cmd/credential-helper/seamark/go.mod new file mode 100644 index 0000000..89f8008 --- /dev/null +++ b/cmd/credential-helper/seamark/go.mod @@ -0,0 +1,10 @@ +module seamark.dev/cmd/credential-helper/seamark + +go 1.26.2 + +// atcr.io is provided by the workspace during local development (go.work +// at the repo root). The require line below is what `go install +// seamark.dev/cmd/credential-helper/seamark@latest` resolves against when +// the module is fetched standalone via the proxy. Bump it when cutting a +// new credhelper release. +require atcr.io v0.1.4 diff --git a/cmd/credential-helper/seamark/go.sum b/cmd/credential-helper/seamark/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/cmd/credential-helper/seamark/main.go b/cmd/credential-helper/seamark/main.go new file mode 100644 index 0000000..1d942eb --- /dev/null +++ b/cmd/credential-helper/seamark/main.go @@ -0,0 +1,28 @@ +// docker-credential-seamark is the Docker credential helper for seamark.dev. +// +// Thin main that delegates to atcr.io/pkg/credhelper. The atcr.io sibling +// at ../atcr mirrors this with brand-specific Config values. +package main + +import "atcr.io/pkg/credhelper" + +// Stamped at link time via -ldflags by the Makefile and goreleaser. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + credhelper.Run(credhelper.Config{ + BinaryName: "docker-credential-seamark", + DefaultRegistry: "seamark.cr", + ConfigDirName: ".seamark", + SecretPrefix: "seamark_device_", + UpdateAssetName: "docker-credential-seamark", + ReleasesBaseURL: "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64", + Version: version, + Commit: commit, + Date: date, + }) +} diff --git a/go.mod b/go.mod index 5dbe974..d7a47a7 100644 --- a/go.mod +++ b/go.mod @@ -184,6 +184,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect + go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 // indirect diff --git a/go.sum b/go.sum index fd2db9a..f0f4f42 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= -go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= diff --git a/go.work b/go.work index c525ec6..f7e4985 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,8 @@ go 1.26.2 use ( . + ./cmd/credential-helper/atcr + ./cmd/credential-helper/seamark ./deploy/upcloud ./scanner ) diff --git a/pkg/appview/middleware/goimport.go b/pkg/appview/middleware/goimport.go index 1815058..b348ad4 100644 --- a/pkg/appview/middleware/goimport.go +++ b/pkg/appview/middleware/goimport.go @@ -4,6 +4,7 @@ import ( "fmt" "html" "net/http" + "strings" ) // GoImport serves the `` tag required by `go install` / @@ -17,6 +18,12 @@ import ( // The meta tag must be present on every subpath under the module root, so this // runs as middleware at the top of the chain and short-circuits any request // carrying `?go-get=1`. +// +// For non-go-get requests on paths that look like Go module subpaths +// (/cmd/, /pkg/, /internal/, /scanner/), the middleware redirects to the +// corresponding source-tree URL in the git host so a browser visit doesn't +// 404. The redirect template mirrors the `go-source` meta tag's +// `{repoURL}/tree/main{/dir}` form. func GoImport(modulePath, repoURL string) func(http.Handler) http.Handler { body := fmt.Sprintf( `go get %s`, @@ -31,13 +38,36 @@ func GoImport(modulePath, repoURL string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("go-get") != "1" { - next.ServeHTTP(w, r) + if r.URL.Query().Get("go-get") == "1" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write([]byte(body)) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=300") - _, _ = w.Write([]byte(body)) + + // Browser visits to recognizable Go subpaths get redirected to + // the repo source tree instead of falling through to the + // appview router (which 404s for these paths). + if isGoModuleSubpath(r.URL.Path) { + target := repoURL + "/tree/main" + r.URL.Path + http.Redirect(w, r, target, http.StatusFound) + return + } + + next.ServeHTTP(w, r) }) } } + +// isGoModuleSubpath reports whether p looks like a Go module source path +// (under cmd/, pkg/, internal/, or scanner/). The check is intentionally +// narrow so the redirect doesn't hijack other appview routes. +func isGoModuleSubpath(p string) bool { + switch { + case strings.HasPrefix(p, "/cmd/"), + strings.HasPrefix(p, "/pkg/"), + strings.HasPrefix(p, "/internal/"): + return true + } + return false +} diff --git a/cmd/credential-helper/cmd_configure.go b/pkg/credhelper/cmd_configure.go similarity index 91% rename from cmd/credential-helper/cmd_configure.go rename to pkg/credhelper/cmd_configure.go index 7b44e56..c690128 100644 --- a/cmd/credential-helper/cmd_configure.go +++ b/pkg/credhelper/cmd_configure.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "encoding/json" @@ -21,20 +21,20 @@ func newConfigureDockerCmd() *cobra.Command { } func runConfigureDocker(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) } - if len(cfg.Registries) == 0 { + if len(sc.Registries) == 0 { fmt.Fprintf(os.Stderr, "No registries configured.\n") - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") + fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName) return nil } // Collect registry hosts var hosts []string - for url := range cfg.Registries { + for url := range sc.Registries { host := strings.TrimPrefix(url, "https://") host = strings.TrimPrefix(host, "http://") hosts = append(hosts, host) @@ -58,11 +58,13 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error { helpersMap = make(map[string]any) } + helper := helperName(cfg) + // Check what needs to change var toAdd []string for _, host := range hosts { current, exists := helpersMap[host] - if !exists || current != "atcr" { + if !exists || current != helper { toAdd = append(toAdd, host) } } @@ -74,7 +76,7 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error { fmt.Printf("Will update %s:\n", dockerConfigPath) for _, host := range toAdd { - fmt.Printf(" + credHelpers[%q] = \"atcr\"\n", host) + fmt.Printf(" + credHelpers[%q] = %q\n", host, helper) } fmt.Println() @@ -90,7 +92,7 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error { // Apply changes for _, host := range toAdd { - helpersMap[host] = "atcr" + helpersMap[host] = helper } dockerCfg["credHelpers"] = helpersMap diff --git a/cmd/credential-helper/cmd_login.go b/pkg/credhelper/cmd_login.go similarity index 93% rename from cmd/credential-helper/cmd_login.go rename to pkg/credhelper/cmd_login.go index 377f0d5..d54560b 100644 --- a/cmd/credential-helper/cmd_login.go +++ b/pkg/credhelper/cmd_login.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "bufio" @@ -12,31 +12,30 @@ import ( ) func newLoginCmd() *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "login [registry]", Short: "Authenticate with a container registry", - Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: atcr.io", + Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: " + cfg.DefaultRegistry, Args: cobra.MaximumNArgs(1), RunE: runLogin, } - return cmd } func runLogin(cmd *cobra.Command, args []string) error { - serverURL := "atcr.io" + serverURL := cfg.DefaultRegistry if len(args) > 0 { serverURL = args[0] } appViewURL := buildAppViewURL(serverURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) } // Check if already logged in - reg := cfg.findRegistry(appViewURL) + reg := sc.findRegistry(appViewURL) if reg != nil && len(reg.Accounts) > 0 { var lines []string for _, acct := range reg.Accounts { @@ -102,8 +101,8 @@ func runLogin(cmd *cobra.Command, args []string) error { logSuccess("Authentication complete.") // 4. Save - cfg.addAccount(resolvedURL, acct) - if err := cfg.save(); err != nil { + sc.addAccount(resolvedURL, acct) + if err := sc.save(); err != nil { return fmt.Errorf("saving config: %w", err) } @@ -174,7 +173,7 @@ func configureDockerForRegistry(serverURL string) error { helpersMap = make(map[string]any) } - helpersMap[host] = "atcr" + helpersMap[host] = helperName(cfg) dockerCfg["credHelpers"] = helpersMap return saveDockerConfig(dockerConfigPath, dockerCfg) diff --git a/cmd/credential-helper/cmd_logout.go b/pkg/credhelper/cmd_logout.go similarity index 88% rename from cmd/credential-helper/cmd_logout.go rename to pkg/credhelper/cmd_logout.go index 1bf4349..e6b7960 100644 --- a/cmd/credential-helper/cmd_logout.go +++ b/pkg/credhelper/cmd_logout.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" @@ -13,26 +13,26 @@ func newLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout [registry]", Short: "Remove account credentials", - Long: "Remove stored credentials for an account.\nDefault registry: atcr.io", + Long: "Remove stored credentials for an account.\nDefault registry: " + cfg.DefaultRegistry, Args: cobra.MaximumNArgs(1), RunE: runLogout, } } func runLogout(cmd *cobra.Command, args []string) error { - serverURL := "atcr.io" + serverURL := cfg.DefaultRegistry if len(args) > 0 { serverURL = args[0] } appViewURL := buildAppViewURL(serverURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) } - reg := cfg.findRegistry(appViewURL) + reg := sc.findRegistry(appViewURL) if reg == nil || len(reg.Accounts) == 0 { fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL) return nil @@ -83,8 +83,8 @@ func runLogout(cmd *cobra.Command, args []string) error { return nil } - cfg.removeAccount(appViewURL, handle) - if err := cfg.save(); err != nil { + sc.removeAccount(appViewURL, handle) + if err := sc.save(); err != nil { return fmt.Errorf("saving config: %w", err) } diff --git a/cmd/credential-helper/cmd_status.go b/pkg/credhelper/cmd_status.go similarity index 83% rename from cmd/credential-helper/cmd_status.go rename to pkg/credhelper/cmd_status.go index 02a8702..c39320e 100644 --- a/cmd/credential-helper/cmd_status.go +++ b/pkg/credhelper/cmd_status.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" @@ -17,26 +17,26 @@ func newStatusCmd() *cobra.Command { } func runStatus(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) } - if len(cfg.Registries) == 0 { + if len(sc.Registries) == 0 { fmt.Fprintf(os.Stderr, "No accounts configured.\n") - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") + fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName) return nil } // Sort registry URLs for stable output var urls []string - for url := range cfg.Registries { + for url := range sc.Registries { urls = append(urls, url) } sort.Strings(urls) for _, url := range urls { - reg := cfg.Registries[url] + reg := sc.Registries[url] fmt.Printf("%s\n", url) // Sort handles for stable output diff --git a/cmd/credential-helper/cmd_switch.go b/pkg/credhelper/cmd_switch.go similarity index 86% rename from cmd/credential-helper/cmd_switch.go rename to pkg/credhelper/cmd_switch.go index 9f289ca..34b2abd 100644 --- a/cmd/credential-helper/cmd_switch.go +++ b/pkg/credhelper/cmd_switch.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" @@ -13,29 +13,29 @@ func newSwitchCmd() *cobra.Command { return &cobra.Command{ Use: "switch [registry]", Short: "Switch the active account for a registry", - Long: "Switch the active account used for Docker operations.\nDefault registry: atcr.io", + Long: "Switch the active account used for Docker operations.\nDefault registry: " + cfg.DefaultRegistry, Args: cobra.MaximumNArgs(1), RunE: runSwitch, } } func runSwitch(cmd *cobra.Command, args []string) error { - serverURL := "atcr.io" + serverURL := cfg.DefaultRegistry if len(args) > 0 { serverURL = args[0] } appViewURL := buildAppViewURL(serverURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) } - reg := cfg.findRegistry(appViewURL) + reg := sc.findRegistry(appViewURL) if reg == nil || len(reg.Accounts) == 0 { fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL) - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") + fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName) return nil } @@ -51,7 +51,7 @@ func runSwitch(cmd *cobra.Command, args []string) error { for h := range reg.Accounts { if h != reg.Active { reg.Active = h - if err := cfg.save(); err != nil { + if err := sc.save(); err != nil { return fmt.Errorf("saving config: %w", err) } fmt.Printf("Switched to %s on %s\n", h, serverURL) @@ -87,7 +87,7 @@ func runSwitch(cmd *cobra.Command, args []string) error { } reg.Active = selected - if err := cfg.save(); err != nil { + if err := sc.save(); err != nil { return fmt.Errorf("saving config: %w", err) } diff --git a/cmd/credential-helper/cmd_update.go b/pkg/credhelper/cmd_update.go similarity index 86% rename from cmd/credential-helper/cmd_update.go rename to pkg/credhelper/cmd_update.go index 07d003a..695107f 100644 --- a/cmd/credential-helper/cmd_update.go +++ b/pkg/credhelper/cmd_update.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" @@ -16,11 +16,6 @@ import ( "github.com/spf13/cobra" ) -// tangledReleasesBase is the tangled.org path for the credential-helper's -// release repository. /tags/latest issues a 302 redirect to the latest tag, -// and /tags/{version}/download/{filename} serves goreleaser artifacts directly. -const tangledReleasesBase = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" - func newUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update", @@ -39,12 +34,12 @@ func runUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("checking for updates: %w", err) } - if !isNewerVersion(latest, version) { - fmt.Printf("You're already running the latest version (%s)\n", version) + if !isNewerVersion(latest, cfg.Version) { + fmt.Printf("You're already running the latest version (%s)\n", cfg.Version) return nil } - fmt.Printf("New version available: %s (current: %s)\n", latest, version) + fmt.Printf("New version available: %s (current: %s)\n", latest, cfg.Version) if checkOnly { return nil @@ -59,13 +54,13 @@ func runUpdate(cmd *cobra.Command, args []string) error { } // fetchLatestVersion resolves the latest released version by following the -// {tangledReleasesBase}/tags/latest redirect chain. Tangled redirects +// {ReleasesBaseURL}/tags/latest redirect chain. Tangled redirects // DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z, so we follow // the chain and read the tag from the final effective URL. func fetchLatestVersion() (string, error) { client := httpClientWithTimeout(10*time.Second, nil) - resp, err := client.Get(tangledReleasesBase + "/tags/latest") + resp, err := client.Get(cfg.ReleasesBaseURL + "/tags/latest") if err != nil { return "", fmt.Errorf("fetching latest tag: %w", err) } @@ -115,7 +110,7 @@ func isNewerVersion(newVersion, currentVersion string) bool { // goreleaserArchiveName returns the archive filename goreleaser publishes for // the given version and the current platform. The naming template lives in -// .goreleaser.yaml: docker-credential-atcr_{Version}_{Title(OS)}_{Arch} with +// .goreleaser.yaml: _{Version}_{Title(OS)}_{Arch} with // amd64→x86_64 and 386→i386. func goreleaserArchiveName(version string) string { versionNoV := strings.TrimPrefix(version, "v") @@ -130,17 +125,17 @@ func goreleaserArchiveName(version string) string { arch = "i386" } - return fmt.Sprintf("docker-credential-atcr_%s_%s_%s.tar.gz", versionNoV, os, arch) + return fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.UpdateAssetName, versionNoV, os, arch) } // performUpdate downloads and installs the new version func performUpdate(latest string) error { filename := goreleaserArchiveName(latest) - downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", tangledReleasesBase, latest, filename) + downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", cfg.ReleasesBaseURL, latest, filename) fmt.Printf("Downloading update from %s...\n", downloadURL) - tmpDir, err := os.MkdirTemp("", "atcr-update-") + tmpDir, err := os.MkdirTemp("", cfg.BinaryName+"-update-") if err != nil { return fmt.Errorf("creating temp directory: %w", err) } @@ -151,7 +146,7 @@ func performUpdate(latest string) error { return fmt.Errorf("downloading: %w", err) } - binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") + binaryPath := filepath.Join(tmpDir, cfg.BinaryName) if runtime.GOOS == "windows" { binaryPath += ".exe" } diff --git a/cmd/credential-helper/config.go b/pkg/credhelper/config.go similarity index 80% rename from cmd/credential-helper/config.go rename to pkg/credhelper/config.go index fa8826c..0750826 100644 --- a/cmd/credential-helper/config.go +++ b/pkg/credhelper/config.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "encoding/json" @@ -7,8 +7,9 @@ import ( "time" ) -// Config is the top-level credential helper configuration (v2). -type Config struct { +// StoredConfig is the on-disk credential helper state (v2). Distinct from +// the per-binary [Config] passed to [Run]. +type StoredConfig struct { Version int `json:"version"` Registries map[string]*RegistryConfig `json:"registries"` } @@ -34,21 +35,21 @@ type UpdateCheckCache struct { } // loadConfig loads the config from disk, auto-migrating old formats. -// Returns a valid Config (possibly empty) even on error. -func loadConfig() (*Config, error) { +// Returns a valid StoredConfig (possibly empty) even on error. +func loadConfig() (*StoredConfig, error) { path := getConfigPath() data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return newConfig(), nil + return newStoredConfig(), nil } - return newConfig(), err + return newStoredConfig(), err } // Try v2 format first - var cfg Config - if err := json.Unmarshal(data, &cfg); err == nil && cfg.Version == 2 && cfg.Registries != nil { - return &cfg, nil + var sc StoredConfig + if err := json.Unmarshal(data, &sc); err == nil && sc.Version == 2 && sc.Registries != nil { + return &sc, nil } // Try current multi-registry format: {"credentials": {"url": {...}}} @@ -61,7 +62,7 @@ func loadConfig() (*Config, error) { } `json:"credentials"` } if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil { - migrated := newConfig() + migrated := newStoredConfig() for appViewURL, cred := range multiCreds.Credentials { handle := cred.Handle if handle == "" { @@ -91,11 +92,11 @@ func loadConfig() (*Config, error) { AppViewURL string `json:"appview_url"` } if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" { - migrated := newConfig() + migrated := newStoredConfig() handle := legacy.Handle registryURL := legacy.AppViewURL if registryURL == "" { - registryURL = "https://atcr.io" + registryURL = "https://" + cfg.DefaultRegistry } reg := migrated.getOrCreateRegistry(registryURL) reg.Accounts[handle] = &Account{ @@ -109,18 +110,18 @@ func loadConfig() (*Config, error) { return migrated, nil } - return newConfig(), fmt.Errorf("unrecognized config format") + return newStoredConfig(), fmt.Errorf("unrecognized config format") } -func newConfig() *Config { - return &Config{ +func newStoredConfig() *StoredConfig { + return &StoredConfig{ Version: 2, Registries: make(map[string]*RegistryConfig), } } // save writes the config to disk. -func (c *Config) save() error { +func (c *StoredConfig) save() error { path := getConfigPath() data, err := json.MarshalIndent(c, "", " ") if err != nil { @@ -130,7 +131,7 @@ func (c *Config) save() error { } // getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL. -func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig { +func (c *StoredConfig) getOrCreateRegistry(registryURL string) *RegistryConfig { reg, ok := c.Registries[registryURL] if !ok { reg = &RegistryConfig{ @@ -142,7 +143,7 @@ func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig { } // findRegistry looks up a RegistryConfig by registry URL. -func (c *Config) findRegistry(registryURL string) *RegistryConfig { +func (c *StoredConfig) findRegistry(registryURL string) *RegistryConfig { return c.Registries[registryURL] } @@ -152,10 +153,10 @@ func (c *Config) findRegistry(registryURL string) *RegistryConfig { // 2. Active account (set by `switch`) // 3. Sole account (if only one exists) // 4. Error -func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) { +func (c *StoredConfig) resolveAccount(registryURL, serverURL string) (*Account, error) { reg := c.findRegistry(registryURL) if reg == nil || len(reg.Accounts) == 0 { - return nil, fmt.Errorf("no accounts configured for %s\nRun: docker-credential-atcr login", serverURL) + return nil, fmt.Errorf("no accounts configured for %s\nRun: %s login", serverURL, cfg.BinaryName) } // 1. Try to detect identity from parent process @@ -182,11 +183,11 @@ func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) } // 4. Ambiguous - return nil, fmt.Errorf("multiple accounts configured for %s\nRun: docker-credential-atcr switch", serverURL) + return nil, fmt.Errorf("multiple accounts configured for %s\nRun: %s switch", serverURL, cfg.BinaryName) } // addAccount adds or updates an account in a registry and sets it active. -func (c *Config) addAccount(registryURL string, acct *Account) { +func (c *StoredConfig) addAccount(registryURL string, acct *Account) { reg := c.getOrCreateRegistry(registryURL) reg.Accounts[acct.Handle] = acct reg.Active = acct.Handle @@ -194,7 +195,7 @@ func (c *Config) addAccount(registryURL string, acct *Account) { // removeAccount removes an account from a registry. // If it was the active account, clears active (or sets to remaining account if exactly one left). -func (c *Config) removeAccount(registryURL, handle string) { +func (c *StoredConfig) removeAccount(registryURL, handle string) { reg := c.findRegistry(registryURL) if reg == nil { return @@ -223,7 +224,7 @@ func getUpdateCheckCachePath() string { if err != nil { return "" } - return fmt.Sprintf("%s/.atcr/update-check.json", homeDir) + return fmt.Sprintf("%s/%s/update-check.json", homeDir, cfg.ConfigDirName) } // loadUpdateCheckCache loads the update check cache from disk diff --git a/cmd/credential-helper/detect.go b/pkg/credhelper/detect.go similarity index 97% rename from cmd/credential-helper/detect.go rename to pkg/credhelper/detect.go index 58abb1d..ed0c5cf 100644 --- a/cmd/credential-helper/detect.go +++ b/pkg/credhelper/detect.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "os" @@ -94,7 +94,7 @@ func parseImageRef(s string, matchHost string) *ImageRef { parts := strings.Split(refPart, "/") - // ATCR pattern requires host/identity/repo (3+ parts) + // Registry pattern requires host/identity/repo (3+ parts) if len(parts) < 3 { return nil } diff --git a/cmd/credential-helper/device_auth.go b/pkg/credhelper/device_auth.go similarity index 99% rename from cmd/credential-helper/device_auth.go rename to pkg/credhelper/device_auth.go index 1a2ceb7..3164cff 100644 --- a/cmd/credential-helper/device_auth.go +++ b/pkg/credhelper/device_auth.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "bytes" diff --git a/cmd/credential-helper/helpers.go b/pkg/credhelper/helpers.go similarity index 93% rename from cmd/credential-helper/helpers.go rename to pkg/credhelper/helpers.go index ce80f2e..15506f6 100644 --- a/cmd/credential-helper/helpers.go +++ b/pkg/credhelper/helpers.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "encoding/json" @@ -172,7 +172,8 @@ func isTerminal(f *os.File) bool { return (stat.Mode() & os.ModeCharDevice) != 0 } -// getConfigDir returns the path to the .atcr config directory, creating it if needed +// getConfigDir returns the per-brand config directory under $HOME, creating +// it if needed. The directory name comes from cfg.ConfigDirName. func getConfigDir() string { homeDir, err := os.UserHomeDir() if err != nil { @@ -180,13 +181,13 @@ func getConfigDir() string { os.Exit(1) } - atcrDir := filepath.Join(homeDir, ".atcr") - if err := os.MkdirAll(atcrDir, 0700); err != nil { - fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err) + dir := filepath.Join(homeDir, cfg.ConfigDirName) + if err := os.MkdirAll(dir, 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error creating %s directory: %v\n", cfg.ConfigDirName, err) os.Exit(1) } - return atcrDir + return dir } // getConfigPath returns the path to the device configuration file diff --git a/cmd/credential-helper/http.go b/pkg/credhelper/http.go similarity index 90% rename from cmd/credential-helper/http.go rename to pkg/credhelper/http.go index 78e3865..d791dfc 100644 --- a/cmd/credential-helper/http.go +++ b/pkg/credhelper/http.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" @@ -10,19 +10,19 @@ import ( // userAgent returns the User-Agent string for outgoing HTTP requests. // -// Format: docker-credential-atcr/ (/; commit ) +// Format: / (/; commit ) // // Format follows the convention Docker's own clients use, so it parses // cleanly with the same regexes server-side log analyzers already // understand. The commit suffix lets users on the device-approval page // distinguish two devices on the same version line if they ever need to. func userAgent() string { - short := commit + short := cfg.Commit if len(short) > 7 { short = short[:7] } - return fmt.Sprintf("docker-credential-atcr/%s (%s/%s; commit %s)", - version, runtime.GOOS, runtime.GOARCH, short) + return fmt.Sprintf("%s/%s (%s/%s; commit %s)", + cfg.BinaryName, cfg.Version, runtime.GOOS, runtime.GOARCH, short) } // uaTransport wraps another RoundTripper and sets the User-Agent header diff --git a/cmd/credential-helper/http_test.go b/pkg/credhelper/http_test.go similarity index 75% rename from cmd/credential-helper/http_test.go rename to pkg/credhelper/http_test.go index 9caa4f6..fc4f4ca 100644 --- a/cmd/credential-helper/http_test.go +++ b/pkg/credhelper/http_test.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "net/http" @@ -8,12 +8,16 @@ import ( ) func TestUserAgent_Format(t *testing.T) { - old := commit - commit = "abc1234deadbeef" - t.Cleanup(func() { commit = old }) + old := cfg + cfg = Config{ + BinaryName: "docker-credential-test", + Version: "0.0.0", + Commit: "abc1234deadbeef", + } + t.Cleanup(func() { cfg = old }) ua := userAgent() - if !strings.HasPrefix(ua, "docker-credential-atcr/") { + if !strings.HasPrefix(ua, "docker-credential-test/") { t.Errorf("UA missing product prefix: %q", ua) } if !strings.Contains(ua, "commit abc1234)") { @@ -25,6 +29,14 @@ func TestUserAgent_Format(t *testing.T) { } func TestHTTPClient_SetsUserAgent(t *testing.T) { + old := cfg + cfg = Config{ + BinaryName: "docker-credential-test", + Version: "0.0.0", + Commit: "abc1234", + } + t.Cleanup(func() { cfg = old }) + var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("User-Agent") @@ -45,6 +57,10 @@ func TestHTTPClient_SetsUserAgent(t *testing.T) { } func TestHTTPClient_RespectsExplicitUserAgent(t *testing.T) { + old := cfg + cfg = Config{BinaryName: "docker-credential-test"} + t.Cleanup(func() { cfg = old }) + var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Get("User-Agent") diff --git a/cmd/credential-helper/process_darwin.go b/pkg/credhelper/process_darwin.go similarity index 99% rename from cmd/credential-helper/process_darwin.go rename to pkg/credhelper/process_darwin.go index d804b19..0c38e00 100644 --- a/cmd/credential-helper/process_darwin.go +++ b/pkg/credhelper/process_darwin.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "bytes" diff --git a/cmd/credential-helper/process_linux.go b/pkg/credhelper/process_linux.go similarity index 98% rename from cmd/credential-helper/process_linux.go rename to pkg/credhelper/process_linux.go index 729ef7c..bf9fa61 100644 --- a/cmd/credential-helper/process_linux.go +++ b/pkg/credhelper/process_linux.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "fmt" diff --git a/cmd/credential-helper/process_other.go b/pkg/credhelper/process_other.go similarity index 96% rename from cmd/credential-helper/process_other.go rename to pkg/credhelper/process_other.go index c7da6a8..15843be 100644 --- a/cmd/credential-helper/process_other.go +++ b/pkg/credhelper/process_other.go @@ -1,6 +1,6 @@ //go:build !linux && !darwin -package main +package credhelper import ( "fmt" diff --git a/cmd/credential-helper/protocol.go b/pkg/credhelper/protocol.go similarity index 82% rename from cmd/credential-helper/protocol.go rename to pkg/credhelper/protocol.go index 2660f18..5587c6f 100644 --- a/cmd/credential-helper/protocol.go +++ b/pkg/credhelper/protocol.go @@ -1,4 +1,4 @@ -package main +package credhelper import ( "encoding/json" @@ -58,9 +58,9 @@ func runGet(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "The 'get' command is part of the Docker credential helper protocol.\n") fmt.Fprintf(os.Stderr, "It should not be run directly.\n\n") fmt.Fprintf(os.Stderr, "To authenticate with a registry, run:\n") - fmt.Fprintf(os.Stderr, " docker-credential-atcr login\n\n") + fmt.Fprintf(os.Stderr, " %s login\n\n", cfg.BinaryName) fmt.Fprintf(os.Stderr, "To check your accounts:\n") - fmt.Fprintf(os.Stderr, " docker-credential-atcr status\n") + fmt.Fprintf(os.Stderr, " %s status\n", cfg.BinaryName) return fmt.Errorf("not a pipe") } @@ -72,12 +72,12 @@ func runGet(cmd *cobra.Command, args []string) error { appViewURL := buildAppViewURL(serverURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) } - acct, err := cfg.resolveAccount(appViewURL, serverURL) + acct, err := sc.resolveAccount(appViewURL, serverURL) if err != nil { return err } @@ -98,9 +98,9 @@ func runGet(cmd *cobra.Command, args []string) error { // Generic auth failure — remove the bad account fmt.Fprintf(os.Stderr, "Credentials for %s are invalid.\n", acct.Handle) - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") - cfg.removeAccount(appViewURL, acct.Handle) - cfg.save() //nolint:errcheck + fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName) + sc.removeAccount(appViewURL, acct.Handle) + sc.save() //nolint:errcheck return fmt.Errorf("invalid credentials") } @@ -123,25 +123,25 @@ func runStore(cmd *cobra.Command, args []string) error { return fmt.Errorf("decoding credentials: %w", err) } - // Only store if the secret looks like a device secret - if !strings.HasPrefix(creds.Secret, "atcr_device_") { + // Only store if the secret looks like one of our device secrets + if !strings.HasPrefix(creds.Secret, cfg.SecretPrefix) { // Not our device secret — ignore (e.g., docker login with app-password) return nil } appViewURL := buildAppViewURL(creds.ServerURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) } - cfg.addAccount(appViewURL, &Account{ + sc.addAccount(appViewURL, &Account{ Handle: creds.Username, DeviceSecret: creds.Secret, }) - return cfg.save() + return sc.save() } func runErase(cmd *cobra.Command, args []string) error { @@ -152,12 +152,12 @@ func runErase(cmd *cobra.Command, args []string) error { appViewURL := buildAppViewURL(serverURL) - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { return nil // No config, nothing to erase } - reg := cfg.findRegistry(appViewURL) + reg := sc.findRegistry(appViewURL) if reg == nil { return nil } @@ -173,12 +173,12 @@ func runErase(cmd *cobra.Command, args []string) error { return nil } - cfg.removeAccount(appViewURL, handle) - return cfg.save() + sc.removeAccount(appViewURL, handle) + return sc.save() } func runList(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() + sc, err := loadConfig() if err != nil { // Return empty object fmt.Println("{}") @@ -187,7 +187,7 @@ func runList(cmd *cobra.Command, args []string) error { // Docker list protocol: {"ServerURL": "Username", ...} result := make(map[string]string) - for url, reg := range cfg.Registries { + for url, reg := range sc.Registries { // Strip scheme for Docker compatibility host := strings.TrimPrefix(url, "https://") host = strings.TrimPrefix(host, "http://") @@ -202,11 +202,11 @@ func runList(cmd *cobra.Command, args []string) error { // checkAndNotifyUpdate checks for updates in the background and notifies the user func checkAndNotifyUpdate() { cache := loadUpdateCheckCache() - if cache != nil && cache.Current == version { + if cache != nil && cache.Current == cfg.Version { // Cache is fresh and for current version - if isNewerVersion(cache.Latest, version) { - fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, version) - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") + if isNewerVersion(cache.Latest, cfg.Version) { + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, cfg.Version) + fmt.Fprintf(os.Stderr, "Run: %s update\n\n", cfg.BinaryName) } // Check if cache is still fresh (24h) if cache.CheckedAt.Add(updateCheckCacheTTL).After(timeNow()) { @@ -222,11 +222,11 @@ func checkAndNotifyUpdate() { saveUpdateCheckCache(&UpdateCheckCache{ CheckedAt: timeNow(), Latest: latest, - Current: version, + Current: cfg.Version, }) - if isNewerVersion(latest, version) { - fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, version) - fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") + if isNewerVersion(latest, cfg.Version) { + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, cfg.Version) + fmt.Fprintf(os.Stderr, "Run: %s update\n\n", cfg.BinaryName) } } diff --git a/pkg/credhelper/run.go b/pkg/credhelper/run.go new file mode 100644 index 0000000..9e72e00 --- /dev/null +++ b/pkg/credhelper/run.go @@ -0,0 +1,101 @@ +// Package credhelper implements the Docker credential helper for ATProto-backed +// container registries. It is consumed by per-brand main packages (e.g. +// cmd/credential-helper/atcr, cmd/credential-helper/seamark) that supply a +// Config and call Run. +package credhelper + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// Config holds the per-brand identity for one built binary. +type Config struct { + // BinaryName is the program name (e.g. "docker-credential-atcr"). Used in + // the root cobra Use line, help text, and the User-Agent. + BinaryName string + + // DefaultRegistry is the default registry hostname used when login/logout/ + // switch is invoked without a positional argument (e.g. "atcr.io"). + DefaultRegistry string + + // ConfigDirName is the directory under $HOME where stored credentials live + // (e.g. ".atcr"). Created with 0700 perms on first use. + ConfigDirName string + + // SecretPrefix is the magic prefix the helper uses to recognise its own + // device-issued secrets when Docker calls `store` (e.g. "atcr_device_"). + // Secrets not carrying this prefix are silently ignored. + SecretPrefix string + + // UpdateAssetName is the goreleaser archive prefix (e.g. + // "docker-credential-atcr") used to construct download URLs in + // `update`. + UpdateAssetName string + + // ReleasesBaseURL is the tangled.org base URL for fetching latest tag + // and downloading release artifacts. + ReleasesBaseURL string + + // Version, Commit, Date are stamped at link time via -ldflags. + Version, Commit, Date string +} + +// helperName derives the Docker credHelpers map value from the binary name. +// Docker discovers helpers by `docker-credential-`; the map value must +// be exactly the suffix. +func helperName(cfg Config) string { + return strings.TrimPrefix(cfg.BinaryName, "docker-credential-") +} + +// Package-level state. Run() initialises `cfg` before any cobra command +// executes; helpers (http.go, etc.) read from it. +var cfg Config + +// timeNow is a variable so tests can override it. +var timeNow = time.Now + +// updateCheckCacheTTL is how long an update check is cached on disk. +const updateCheckCacheTTL = 24 * time.Hour + +// Run executes the credential helper CLI with the supplied per-brand Config. +// Intended to be called from a thin main package; never returns under normal +// operation (calls os.Exit on cobra error). +func Run(c Config) { + cfg = c + + rootCmd := &cobra.Command{ + Use: cfg.BinaryName, + Short: cfg.BinaryName + " — ATProto container registry credential helper", + Long: fmt.Sprintf(`%s manages authentication for ATProto-backed container registries. + +It implements the Docker credential helper protocol and provides commands +for managing multiple accounts across multiple registries.`, cfg.BinaryName), + Version: fmt.Sprintf("%s (commit: %s, built: %s)", cfg.Version, cfg.Commit, cfg.Date), + SilenceUsage: true, + SilenceErrors: true, + } + + // Docker protocol commands (hidden — called by Docker, not users) + rootCmd.AddCommand(newGetCmd()) + rootCmd.AddCommand(newStoreCmd()) + rootCmd.AddCommand(newEraseCmd()) + rootCmd.AddCommand(newListCmd()) + + // User-facing commands + rootCmd.AddCommand(newLoginCmd()) + rootCmd.AddCommand(newLogoutCmd()) + rootCmd.AddCommand(newStatusCmd()) + rootCmd.AddCommand(newSwitchCmd()) + rootCmd.AddCommand(newConfigureDockerCmd()) + rootCmd.AddCommand(newUpdateCmd()) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +}