mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
create a seamark credential helper, have the appview redirect to the source if navigating to go paths
This commit is contained in:
+3
-1
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -2,6 +2,8 @@ go 1.26.2
|
||||
|
||||
use (
|
||||
.
|
||||
./cmd/credential-helper/atcr
|
||||
./cmd/credential-helper/seamark
|
||||
./deploy/upcloud
|
||||
./scanner
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GoImport serves the `<meta name="go-import">` 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(
|
||||
`<!DOCTYPE html><html><head><meta name="go-import" content="%s git %s"><meta name="go-source" content="%s %s %s/tree/main{/dir} %s/tree/main{/dir}/{file}#L{line}"></head><body>go get %s</body></html>`,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: <UpdateAssetName>_{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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package credhelper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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
|
||||
@@ -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/<version> (<os>/<arch>; commit <short>)
|
||||
// Format: <binary-name>/<version> (<os>/<arch>; commit <short>)
|
||||
//
|
||||
// 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
|
||||
@@ -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")
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package credhelper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package credhelper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !linux && !darwin
|
||||
|
||||
package main
|
||||
package credhelper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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-<name>`; 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user