···8899WORKDIR /build
10101111-# Copy go.work and both module definitions first for layer caching
1212-COPY go.work ./
1111+# Disable workspace mode — go.work references modules not in the Docker context
1212+ENV GOWORK=off
1313+1414+# Copy module definitions first for layer caching
1315COPY go.mod go.sum ./
1416COPY scanner/go.mod scanner/go.sum ./scanner/
1517
+29-24
Formula/docker-credential-atcr.rb
···44class DockerCredentialAtcr < Formula
55 desc "Docker credential helper for ATCR (ATProto Container Registry)"
66 homepage "https://atcr.io"
77- url "https://github.com/atcr-io/atcr/archive/refs/tags/v0.0.1.tar.gz"
88- sha256 "REPLACE_WITH_TARBALL_SHA256"
77+ version "0.0.1"
98 license "MIT"
1010- head "https://github.com/atcr-io/atcr.git", branch: "main"
99+1010+ on_macos do
1111+ on_arm do
1212+ url "https://tangled.org/evan.jarrett.net/at-container-registry/tags/v0.0.1/download/docker-credential-atcr_0.0.1_Darwin_arm64.tar.gz"
1313+ sha256 "REPLACE_WITH_SHA256"
1414+ end
1515+ on_intel do
1616+ url "https://tangled.org/evan.jarrett.net/at-container-registry/tags/v0.0.1/download/docker-credential-atcr_0.0.1_Darwin_x86_64.tar.gz"
1717+ sha256 "REPLACE_WITH_SHA256"
1818+ end
1919+ end
11201212- depends_on "go" => :build
2121+ on_linux do
2222+ on_arm do
2323+ url "https://tangled.org/evan.jarrett.net/at-container-registry/tags/v0.0.1/download/docker-credential-atcr_0.0.1_Linux_arm64.tar.gz"
2424+ sha256 "REPLACE_WITH_SHA256"
2525+ end
2626+ on_intel do
2727+ url "https://tangled.org/evan.jarrett.net/at-container-registry/tags/v0.0.1/download/docker-credential-atcr_0.0.1_Linux_x86_64.tar.gz"
2828+ sha256 "REPLACE_WITH_SHA256"
2929+ end
3030+ end
13311432 def install
1515- # Build the credential helper binary
1616- # Use ldflags to inject version information
1717- ldflags = %W[
1818- -s -w
1919- -X main.version=#{version}
2020- -X main.commit=#{tap.user}
2121- -X main.date=#{time.iso8601}
2222- ]
2323-2424- system "go", "build", *std_go_args(ldflags:, output: bin/"docker-credential-atcr"), "./cmd/credential-helper"
3333+ bin.install "docker-credential-atcr"
2534 end
26352736 test do
2828- # Test that the binary exists and is executable
2937 assert_match version.to_s, shell_output("#{bin}/docker-credential-atcr version 2>&1")
3038 end
3139···3442 To configure Docker to use ATCR credential helper, add the following
3543 to your ~/.docker/config.json:
36443737- {
3838- "credHelpers": {
3939- "atcr.io": "atcr"
4545+ {
4646+ "credHelpers": {
4747+ "atcr.io": "atcr"
4848+ }
4049 }
4141- }
42504343- Note: The credential helper name is "atcr" (Docker automatically prefixes
4444- with "docker-credential-" when looking for the binary).
5151+ Or run: docker-credential-atcr configure-docker
45524653 To authenticate with ATCR:
4754 docker push atcr.io/<your-handle>/<image>:latest
48554949- This will open your browser to complete the OAuth device flow.
5050-5151- Configuration is stored in: ~/.atcr/device.json
5656+ Configuration is stored in: ~/.atcr/config.json
5257 EOS
5358 end
5459end
+18-3
Makefile
···3344.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
55 generate test test-race test-verbose lint clean help install-credential-helper \
66- develop develop-detached develop-down dev
66+ develop develop-detached develop-down dev \
77+ docker docker-appview docker-hold docker-scanner
7889.DEFAULT_GOAL := help
910···4041 @mkdir -p bin
4142 go build -o bin/atcr-hold ./cmd/hold
42434343-build-credential-helper: $(GENERATED_ASSETS) ## Build credential helper only
4444+build-credential-helper: ## Build credential helper only
4445 @echo "→ Building credential helper..."
4546 @mkdir -p bin
4647 go build -o bin/docker-credential-atcr ./cmd/credential-helper
47484848-build-oauth-helper: $(GENERATED_ASSETS) ## Build OAuth helper only
4949+build-oauth-helper: ## Build OAuth helper only
4950 @echo "→ Building OAuth helper..."
5051 @mkdir -p bin
5152 go build -o bin/oauth-helper ./cmd/oauth-helper
···8889 air -c .air.toml
89909091##@ Docker Targets
9292+9393+docker: docker-appview docker-hold docker-scanner ## Build all Docker images
9494+9595+docker-appview: ## Build appview Docker image
9696+ @echo "→ Building appview Docker image..."
9797+ docker build -f Dockerfile.appview -t atcr.io/atcr.io/appview:latest .
9898+9999+docker-hold: ## Build hold Docker image
100100+ @echo "→ Building hold Docker image..."
101101+ docker build -f Dockerfile.hold -t atcr.io/atcr.io/hold:latest .
102102+103103+docker-scanner: ## Build scanner Docker image
104104+ @echo "→ Building scanner Docker image..."
105105+ docker build -f Dockerfile.scanner -t atcr.io/atcr.io/scanner:latest .
9110692107develop: ## Build and start docker-compose with Air hot reload
93108 @echo "→ Building Docker images..."
+159
cmd/credential-helper/cmd_configure.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "os"
77+ "path/filepath"
88+ "strings"
99+1010+ "github.com/charmbracelet/huh"
1111+ "github.com/spf13/cobra"
1212+)
1313+1414+func newConfigureDockerCmd() *cobra.Command {
1515+ return &cobra.Command{
1616+ Use: "configure-docker",
1717+ Short: "Configure Docker to use this credential helper",
1818+ Long: "Adds or updates the credHelpers entry in ~/.docker/config.json\nfor all configured registries.",
1919+ RunE: runConfigureDocker,
2020+ }
2121+}
2222+2323+func runConfigureDocker(cmd *cobra.Command, args []string) error {
2424+ cfg, err := loadConfig()
2525+ if err != nil {
2626+ return fmt.Errorf("loading config: %w", err)
2727+ }
2828+2929+ if len(cfg.Registries) == 0 {
3030+ fmt.Fprintf(os.Stderr, "No registries configured.\n")
3131+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
3232+ return nil
3333+ }
3434+3535+ // Collect registry hosts
3636+ var hosts []string
3737+ for url := range cfg.Registries {
3838+ host := strings.TrimPrefix(url, "https://")
3939+ host = strings.TrimPrefix(host, "http://")
4040+ hosts = append(hosts, host)
4141+ }
4242+4343+ dockerConfigPath := getDockerConfigPath()
4444+4545+ // Load existing Docker config
4646+ dockerCfg := loadDockerConfig()
4747+ if dockerCfg == nil {
4848+ dockerCfg = make(map[string]any)
4949+ }
5050+5151+ // Get or create credHelpers
5252+ helpers, ok := dockerCfg["credHelpers"]
5353+ if !ok {
5454+ helpers = make(map[string]any)
5555+ }
5656+ helpersMap, ok := helpers.(map[string]any)
5757+ if !ok {
5858+ helpersMap = make(map[string]any)
5959+ }
6060+6161+ // Check what needs to change
6262+ var toAdd []string
6363+ for _, host := range hosts {
6464+ current, exists := helpersMap[host]
6565+ if !exists || current != "atcr" {
6666+ toAdd = append(toAdd, host)
6767+ }
6868+ }
6969+7070+ if len(toAdd) == 0 {
7171+ fmt.Printf("Docker is already configured for all registries.\n")
7272+ return nil
7373+ }
7474+7575+ fmt.Printf("Will update %s:\n", dockerConfigPath)
7676+ for _, host := range toAdd {
7777+ fmt.Printf(" + credHelpers[%q] = \"atcr\"\n", host)
7878+ }
7979+ fmt.Println()
8080+8181+ var confirm bool
8282+ err = huh.NewConfirm().
8383+ Title("Apply changes?").
8484+ Value(&confirm).
8585+ Run()
8686+ if err != nil || !confirm {
8787+ fmt.Fprintf(os.Stderr, "Cancelled.\n")
8888+ return nil
8989+ }
9090+9191+ // Apply changes
9292+ for _, host := range toAdd {
9393+ helpersMap[host] = "atcr"
9494+ }
9595+ dockerCfg["credHelpers"] = helpersMap
9696+9797+ // Remove conflicting credsStore if it exists and we're adding credHelpers
9898+ if _, hasStore := dockerCfg["credsStore"]; hasStore {
9999+ fmt.Fprintf(os.Stderr, "Note: credsStore is set — credHelpers takes precedence for configured registries.\n")
100100+ }
101101+102102+ if err := saveDockerConfig(dockerConfigPath, dockerCfg); err != nil {
103103+ return fmt.Errorf("saving Docker config: %w", err)
104104+ }
105105+106106+ fmt.Printf("Docker configured successfully.\n")
107107+ return nil
108108+}
109109+110110+// getDockerConfigPath returns the path to Docker's config.json
111111+func getDockerConfigPath() string {
112112+ // Check DOCKER_CONFIG env var first
113113+ if dir := os.Getenv("DOCKER_CONFIG"); dir != "" {
114114+ return filepath.Join(dir, "config.json")
115115+ }
116116+117117+ homeDir, err := os.UserHomeDir()
118118+ if err != nil {
119119+ return ""
120120+ }
121121+ return filepath.Join(homeDir, ".docker", "config.json")
122122+}
123123+124124+// loadDockerConfig loads Docker's config.json as a generic map
125125+func loadDockerConfig() map[string]any {
126126+ path := getDockerConfigPath()
127127+ if path == "" {
128128+ return nil
129129+ }
130130+131131+ data, err := os.ReadFile(path)
132132+ if err != nil {
133133+ return nil
134134+ }
135135+136136+ var config map[string]any
137137+ if err := json.Unmarshal(data, &config); err != nil {
138138+ return nil
139139+ }
140140+141141+ return config
142142+}
143143+144144+// saveDockerConfig writes Docker's config.json
145145+func saveDockerConfig(path string, config map[string]any) error {
146146+ // Ensure directory exists
147147+ dir := filepath.Dir(path)
148148+ if err := os.MkdirAll(dir, 0700); err != nil {
149149+ return err
150150+ }
151151+152152+ data, err := json.MarshalIndent(config, "", "\t")
153153+ if err != nil {
154154+ return err
155155+ }
156156+ data = append(data, '\n')
157157+158158+ return os.WriteFile(path, data, 0600)
159159+}
+181
cmd/credential-helper/cmd_login.go
···11+package main
22+33+import (
44+ "bufio"
55+ "fmt"
66+ "os"
77+ "strings"
88+99+ "github.com/charmbracelet/huh"
1010+ "github.com/charmbracelet/huh/spinner"
1111+ "github.com/spf13/cobra"
1212+)
1313+1414+func newLoginCmd() *cobra.Command {
1515+ cmd := &cobra.Command{
1616+ Use: "login [registry]",
1717+ Short: "Authenticate with a container registry",
1818+ Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: atcr.io",
1919+ Args: cobra.MaximumNArgs(1),
2020+ RunE: runLogin,
2121+ }
2222+ return cmd
2323+}
2424+2525+func runLogin(cmd *cobra.Command, args []string) error {
2626+ serverURL := "atcr.io"
2727+ if len(args) > 0 {
2828+ serverURL = args[0]
2929+ }
3030+3131+ appViewURL := buildAppViewURL(serverURL)
3232+3333+ cfg, err := loadConfig()
3434+ if err != nil {
3535+ fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
3636+ }
3737+3838+ // Check if already logged in
3939+ reg := cfg.findRegistry(appViewURL)
4040+ if reg != nil && len(reg.Accounts) > 0 {
4141+ var lines []string
4242+ for _, acct := range reg.Accounts {
4343+ lines = append(lines, acct.Handle)
4444+ }
4545+4646+ var addAnother bool
4747+ err := huh.NewConfirm().
4848+ Title("Already logged in to " + appViewURL).
4949+ Description("Accounts: " + strings.Join(lines, ", ")).
5050+ Value(&addAnother).
5151+ Affirmative("Add another account").
5252+ Negative("Cancel").
5353+ Run()
5454+ if err != nil || !addAnother {
5555+ return nil
5656+ }
5757+ }
5858+5959+ // 1. Request device code
6060+ codeResp, resolvedURL, err := requestDeviceCode(serverURL)
6161+ if err != nil {
6262+ return fmt.Errorf("device authorization failed: %w", err)
6363+ }
6464+6565+ verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode
6666+6767+ // 2. Show code and open browser
6868+ fmt.Fprintln(os.Stderr)
6969+ logWarning("First copy your one-time code: %s", bold(codeResp.UserCode))
7070+7171+ if isTerminal(os.Stdin) {
7272+ // Interactive: wait for Enter before opening browser
7373+ logInfof("Press Enter to open %s in your browser... ", codeResp.VerificationURI)
7474+ reader := bufio.NewReader(os.Stdin)
7575+ reader.ReadString('\n') //nolint:errcheck
7676+7777+ if err := openBrowser(verificationURL); err != nil {
7878+ logWarning("Could not open browser automatically.")
7979+ fmt.Fprintf(os.Stderr, " Visit: %s\n", verificationURL)
8080+ }
8181+ } else {
8282+ // Non-interactive: just print the URL
8383+ logInfo("Visit this URL in your browser:")
8484+ fmt.Fprintf(os.Stderr, " %s\n", verificationURL)
8585+ }
8686+8787+ // 3. Poll for authorization with spinner
8888+ var acct *Account
8989+ var pollErr error
9090+ if err := spinner.New().
9191+ Title("Waiting for authentication...").
9292+ Action(func() {
9393+ acct, pollErr = pollDeviceToken(resolvedURL, codeResp)
9494+ }).
9595+ Run(); err != nil {
9696+ return err
9797+ }
9898+ if pollErr != nil {
9999+ return fmt.Errorf("device authorization failed: %w", pollErr)
100100+ }
101101+102102+ logSuccess("Authentication complete.")
103103+104104+ // 4. Save
105105+ cfg.addAccount(resolvedURL, acct)
106106+ if err := cfg.save(); err != nil {
107107+ return fmt.Errorf("saving config: %w", err)
108108+ }
109109+110110+ logSuccess("Logged in as %s on %s", bold(acct.Handle), resolvedURL)
111111+112112+ // 5. Offer to configure Docker if not already set up
113113+ if isTerminal(os.Stdin) && !isDockerConfigured(serverURL) {
114114+ fmt.Fprintf(os.Stderr, "\n")
115115+ var configureDkr bool
116116+ err := huh.NewConfirm().
117117+ Title("Configure Docker to use this credential helper?").
118118+ Description("Adds credHelpers entry to ~/.docker/config.json").
119119+ Value(&configureDkr).
120120+ Run()
121121+ if err == nil && configureDkr {
122122+ if configureErr := configureDockerForRegistry(serverURL); configureErr != nil {
123123+ logWarning("Failed to configure Docker: %v", configureErr)
124124+ } else {
125125+ logSuccess("Configured Docker for %s", serverURL)
126126+ }
127127+ }
128128+ }
129129+130130+ return nil
131131+}
132132+133133+// isDockerConfigured checks if Docker's config.json has this registry in credHelpers
134134+func isDockerConfigured(serverURL string) bool {
135135+ dockerConfig := loadDockerConfig()
136136+ if dockerConfig == nil {
137137+ return false
138138+ }
139139+140140+ helpers, ok := dockerConfig["credHelpers"]
141141+ if !ok {
142142+ return false
143143+ }
144144+145145+ helpersMap, ok := helpers.(map[string]any)
146146+ if !ok {
147147+ return false
148148+ }
149149+150150+ host := strings.TrimPrefix(serverURL, "https://")
151151+ host = strings.TrimPrefix(host, "http://")
152152+153153+ _, ok = helpersMap[host]
154154+ return ok
155155+}
156156+157157+// configureDockerForRegistry adds a credHelpers entry for a single registry
158158+func configureDockerForRegistry(serverURL string) error {
159159+ host := strings.TrimPrefix(serverURL, "https://")
160160+ host = strings.TrimPrefix(host, "http://")
161161+162162+ dockerConfigPath := getDockerConfigPath()
163163+ dockerCfg := loadDockerConfig()
164164+ if dockerCfg == nil {
165165+ dockerCfg = make(map[string]any)
166166+ }
167167+168168+ helpers, ok := dockerCfg["credHelpers"]
169169+ if !ok {
170170+ helpers = make(map[string]any)
171171+ }
172172+ helpersMap, ok := helpers.(map[string]any)
173173+ if !ok {
174174+ helpersMap = make(map[string]any)
175175+ }
176176+177177+ helpersMap[host] = "atcr"
178178+ dockerCfg["credHelpers"] = helpersMap
179179+180180+ return saveDockerConfig(dockerConfigPath, dockerCfg)
181181+}
+93
cmd/credential-helper/cmd_logout.go
···11+package main
22+33+import (
44+ "fmt"
55+ "os"
66+ "sort"
77+88+ "github.com/charmbracelet/huh"
99+ "github.com/spf13/cobra"
1010+)
1111+1212+func newLogoutCmd() *cobra.Command {
1313+ return &cobra.Command{
1414+ Use: "logout [registry]",
1515+ Short: "Remove account credentials",
1616+ Long: "Remove stored credentials for an account.\nDefault registry: atcr.io",
1717+ Args: cobra.MaximumNArgs(1),
1818+ RunE: runLogout,
1919+ }
2020+}
2121+2222+func runLogout(cmd *cobra.Command, args []string) error {
2323+ serverURL := "atcr.io"
2424+ if len(args) > 0 {
2525+ serverURL = args[0]
2626+ }
2727+2828+ appViewURL := buildAppViewURL(serverURL)
2929+3030+ cfg, err := loadConfig()
3131+ if err != nil {
3232+ return fmt.Errorf("loading config: %w", err)
3333+ }
3434+3535+ reg := cfg.findRegistry(appViewURL)
3636+ if reg == nil || len(reg.Accounts) == 0 {
3737+ fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL)
3838+ return nil
3939+ }
4040+4141+ // Determine which account to remove
4242+ var handle string
4343+4444+ if len(reg.Accounts) == 1 {
4545+ for h := range reg.Accounts {
4646+ handle = h
4747+ }
4848+ } else {
4949+ // Multiple accounts — select which to remove
5050+ var handles []string
5151+ for h := range reg.Accounts {
5252+ handles = append(handles, h)
5353+ }
5454+ sort.Strings(handles)
5555+5656+ var options []huh.Option[string]
5757+ for _, h := range handles {
5858+ label := h
5959+ if h == reg.Active {
6060+ label += " (active)"
6161+ }
6262+ options = append(options, huh.NewOption(label, h))
6363+ }
6464+6565+ err := huh.NewSelect[string]().
6666+ Title("Which account to remove?").
6767+ Options(options...).
6868+ Value(&handle).
6969+ Run()
7070+ if err != nil {
7171+ return err
7272+ }
7373+ }
7474+7575+ // Confirm
7676+ var confirm bool
7777+ err = huh.NewConfirm().
7878+ Title(fmt.Sprintf("Remove %s from %s?", handle, serverURL)).
7979+ Value(&confirm).
8080+ Run()
8181+ if err != nil || !confirm {
8282+ fmt.Fprintf(os.Stderr, "Cancelled.\n")
8383+ return nil
8484+ }
8585+8686+ cfg.removeAccount(appViewURL, handle)
8787+ if err := cfg.save(); err != nil {
8888+ return fmt.Errorf("saving config: %w", err)
8989+ }
9090+9191+ fmt.Printf("Removed %s from %s\n", handle, serverURL)
9292+ return nil
9393+}
+65
cmd/credential-helper/cmd_status.go
···11+package main
22+33+import (
44+ "fmt"
55+ "os"
66+ "sort"
77+88+ "github.com/spf13/cobra"
99+)
1010+1111+func newStatusCmd() *cobra.Command {
1212+ return &cobra.Command{
1313+ Use: "status",
1414+ Short: "Show all configured accounts",
1515+ RunE: runStatus,
1616+ }
1717+}
1818+1919+func runStatus(cmd *cobra.Command, args []string) error {
2020+ cfg, err := loadConfig()
2121+ if err != nil {
2222+ return fmt.Errorf("loading config: %w", err)
2323+ }
2424+2525+ if len(cfg.Registries) == 0 {
2626+ fmt.Fprintf(os.Stderr, "No accounts configured.\n")
2727+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
2828+ return nil
2929+ }
3030+3131+ // Sort registry URLs for stable output
3232+ var urls []string
3333+ for url := range cfg.Registries {
3434+ urls = append(urls, url)
3535+ }
3636+ sort.Strings(urls)
3737+3838+ for _, url := range urls {
3939+ reg := cfg.Registries[url]
4040+ fmt.Printf("%s\n", url)
4141+4242+ // Sort handles for stable output
4343+ var handles []string
4444+ for h := range reg.Accounts {
4545+ handles = append(handles, h)
4646+ }
4747+ sort.Strings(handles)
4848+4949+ for _, handle := range handles {
5050+ acct := reg.Accounts[handle]
5151+ marker := " "
5252+ if handle == reg.Active {
5353+ marker = "* "
5454+ }
5555+ did := ""
5656+ if acct.DID != "" {
5757+ did = fmt.Sprintf(" (%s)", acct.DID)
5858+ }
5959+ fmt.Printf(" %s%s%s\n", marker, handle, did)
6060+ }
6161+ fmt.Println()
6262+ }
6363+6464+ return nil
6565+}
+96
cmd/credential-helper/cmd_switch.go
···11+package main
22+33+import (
44+ "fmt"
55+ "os"
66+ "sort"
77+88+ "github.com/charmbracelet/huh"
99+ "github.com/spf13/cobra"
1010+)
1111+1212+func newSwitchCmd() *cobra.Command {
1313+ return &cobra.Command{
1414+ Use: "switch [registry]",
1515+ Short: "Switch the active account for a registry",
1616+ Long: "Switch the active account used for Docker operations.\nDefault registry: atcr.io",
1717+ Args: cobra.MaximumNArgs(1),
1818+ RunE: runSwitch,
1919+ }
2020+}
2121+2222+func runSwitch(cmd *cobra.Command, args []string) error {
2323+ serverURL := "atcr.io"
2424+ if len(args) > 0 {
2525+ serverURL = args[0]
2626+ }
2727+2828+ appViewURL := buildAppViewURL(serverURL)
2929+3030+ cfg, err := loadConfig()
3131+ if err != nil {
3232+ return fmt.Errorf("loading config: %w", err)
3333+ }
3434+3535+ reg := cfg.findRegistry(appViewURL)
3636+ if reg == nil || len(reg.Accounts) == 0 {
3737+ fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL)
3838+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
3939+ return nil
4040+ }
4141+4242+ if len(reg.Accounts) == 1 {
4343+ for h := range reg.Accounts {
4444+ fmt.Fprintf(os.Stderr, "Only one account (%s) — nothing to switch.\n", h)
4545+ }
4646+ return nil
4747+ }
4848+4949+ // For exactly 2 accounts, just toggle
5050+ if len(reg.Accounts) == 2 {
5151+ for h := range reg.Accounts {
5252+ if h != reg.Active {
5353+ reg.Active = h
5454+ if err := cfg.save(); err != nil {
5555+ return fmt.Errorf("saving config: %w", err)
5656+ }
5757+ fmt.Printf("Switched to %s on %s\n", h, serverURL)
5858+ return nil
5959+ }
6060+ }
6161+ }
6262+6363+ // 3+ accounts: interactive select
6464+ var handles []string
6565+ for h := range reg.Accounts {
6666+ handles = append(handles, h)
6767+ }
6868+ sort.Strings(handles)
6969+7070+ var options []huh.Option[string]
7171+ for _, h := range handles {
7272+ label := h
7373+ if h == reg.Active {
7474+ label += " (current)"
7575+ }
7676+ options = append(options, huh.NewOption(label, h))
7777+ }
7878+7979+ var selected string
8080+ err = huh.NewSelect[string]().
8181+ Title("Select account for " + serverURL).
8282+ Options(options...).
8383+ Value(&selected).
8484+ Run()
8585+ if err != nil {
8686+ return err
8787+ }
8888+8989+ reg.Active = selected
9090+ if err := cfg.save(); err != nil {
9191+ return fmt.Errorf("saving config: %w", err)
9292+ }
9393+9494+ fmt.Printf("Switched to %s on %s\n", selected, serverURL)
9595+ return nil
9696+}
···11+package main
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "os"
77+ "time"
88+)
99+1010+// Config is the top-level credential helper configuration (v2).
1111+type Config struct {
1212+ Version int `json:"version"`
1313+ Registries map[string]*RegistryConfig `json:"registries"`
1414+}
1515+1616+// RegistryConfig holds accounts for a single registry.
1717+type RegistryConfig struct {
1818+ Active string `json:"active"`
1919+ Accounts map[string]*Account `json:"accounts"`
2020+}
2121+2222+// Account holds credentials for a single identity on a registry.
2323+type Account struct {
2424+ Handle string `json:"handle"`
2525+ DID string `json:"did,omitempty"`
2626+ DeviceSecret string `json:"device_secret"`
2727+}
2828+2929+// UpdateCheckCache stores the last update check result.
3030+type UpdateCheckCache struct {
3131+ CheckedAt time.Time `json:"checked_at"`
3232+ Latest string `json:"latest"`
3333+ Current string `json:"current"`
3434+}
3535+3636+// loadConfig loads the config from disk, auto-migrating old formats.
3737+// Returns a valid Config (possibly empty) even on error.
3838+func loadConfig() (*Config, error) {
3939+ path := getConfigPath()
4040+ data, err := os.ReadFile(path)
4141+ if err != nil {
4242+ if os.IsNotExist(err) {
4343+ return newConfig(), nil
4444+ }
4545+ return newConfig(), err
4646+ }
4747+4848+ // Try v2 format first
4949+ var cfg Config
5050+ if err := json.Unmarshal(data, &cfg); err == nil && cfg.Version == 2 && cfg.Registries != nil {
5151+ return &cfg, nil
5252+ }
5353+5454+ // Try current multi-registry format: {"credentials": {"url": {...}}}
5555+ var multiCreds struct {
5656+ Credentials map[string]struct {
5757+ Handle string `json:"handle"`
5858+ DID string `json:"did"`
5959+ DeviceSecret string `json:"device_secret"`
6060+ AppViewURL string `json:"appview_url"`
6161+ } `json:"credentials"`
6262+ }
6363+ if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil {
6464+ migrated := newConfig()
6565+ for appViewURL, cred := range multiCreds.Credentials {
6666+ handle := cred.Handle
6767+ if handle == "" {
6868+ continue
6969+ }
7070+ registryURL := appViewURL
7171+ reg := migrated.getOrCreateRegistry(registryURL)
7272+ reg.Accounts[handle] = &Account{
7373+ Handle: handle,
7474+ DID: cred.DID,
7575+ DeviceSecret: cred.DeviceSecret,
7676+ }
7777+ if reg.Active == "" {
7878+ reg.Active = handle
7979+ }
8080+ }
8181+ if err := migrated.save(); err != nil {
8282+ return migrated, fmt.Errorf("saving migrated config: %w", err)
8383+ }
8484+ return migrated, nil
8585+ }
8686+8787+ // Try legacy single-device format: {"handle": "...", "device_secret": "...", "appview_url": "..."}
8888+ var legacy struct {
8989+ Handle string `json:"handle"`
9090+ DeviceSecret string `json:"device_secret"`
9191+ AppViewURL string `json:"appview_url"`
9292+ }
9393+ if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" {
9494+ migrated := newConfig()
9595+ handle := legacy.Handle
9696+ registryURL := legacy.AppViewURL
9797+ if registryURL == "" {
9898+ registryURL = "https://atcr.io"
9999+ }
100100+ reg := migrated.getOrCreateRegistry(registryURL)
101101+ reg.Accounts[handle] = &Account{
102102+ Handle: handle,
103103+ DeviceSecret: legacy.DeviceSecret,
104104+ }
105105+ reg.Active = handle
106106+ if err := migrated.save(); err != nil {
107107+ return migrated, fmt.Errorf("saving migrated config: %w", err)
108108+ }
109109+ return migrated, nil
110110+ }
111111+112112+ return newConfig(), fmt.Errorf("unrecognized config format")
113113+}
114114+115115+func newConfig() *Config {
116116+ return &Config{
117117+ Version: 2,
118118+ Registries: make(map[string]*RegistryConfig),
119119+ }
120120+}
121121+122122+// save writes the config to disk.
123123+func (c *Config) save() error {
124124+ path := getConfigPath()
125125+ data, err := json.MarshalIndent(c, "", " ")
126126+ if err != nil {
127127+ return err
128128+ }
129129+ return os.WriteFile(path, data, 0600)
130130+}
131131+132132+// getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL.
133133+func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig {
134134+ reg, ok := c.Registries[registryURL]
135135+ if !ok {
136136+ reg = &RegistryConfig{
137137+ Accounts: make(map[string]*Account),
138138+ }
139139+ c.Registries[registryURL] = reg
140140+ }
141141+ return reg
142142+}
143143+144144+// findRegistry looks up a RegistryConfig by registry URL.
145145+func (c *Config) findRegistry(registryURL string) *RegistryConfig {
146146+ return c.Registries[registryURL]
147147+}
148148+149149+// resolveAccount determines which account to use for a given registry.
150150+// Priority:
151151+// 1. Identity detected from parent process command line
152152+// 2. Active account (set by `switch`)
153153+// 3. Sole account (if only one exists)
154154+// 4. Error
155155+func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) {
156156+ reg := c.findRegistry(registryURL)
157157+ if reg == nil || len(reg.Accounts) == 0 {
158158+ return nil, fmt.Errorf("no accounts configured for %s\nRun: docker-credential-atcr login", serverURL)
159159+ }
160160+161161+ // 1. Try to detect identity from parent process
162162+ ref := detectImageRef(serverURL)
163163+ if ref != nil && ref.Identity != "" {
164164+ if acct, ok := reg.Accounts[ref.Identity]; ok {
165165+ return acct, nil
166166+ }
167167+ // Identity detected but no matching account — fall through to active
168168+ }
169169+170170+ // 2. Active account
171171+ if reg.Active != "" {
172172+ if acct, ok := reg.Accounts[reg.Active]; ok {
173173+ return acct, nil
174174+ }
175175+ }
176176+177177+ // 3. Sole account
178178+ if len(reg.Accounts) == 1 {
179179+ for _, acct := range reg.Accounts {
180180+ return acct, nil
181181+ }
182182+ }
183183+184184+ // 4. Ambiguous
185185+ return nil, fmt.Errorf("multiple accounts configured for %s\nRun: docker-credential-atcr switch", serverURL)
186186+}
187187+188188+// addAccount adds or updates an account in a registry and sets it active.
189189+func (c *Config) addAccount(registryURL string, acct *Account) {
190190+ reg := c.getOrCreateRegistry(registryURL)
191191+ reg.Accounts[acct.Handle] = acct
192192+ reg.Active = acct.Handle
193193+}
194194+195195+// removeAccount removes an account from a registry.
196196+// If it was the active account, clears active (or sets to remaining account if exactly one left).
197197+func (c *Config) removeAccount(registryURL, handle string) {
198198+ reg := c.findRegistry(registryURL)
199199+ if reg == nil {
200200+ return
201201+ }
202202+203203+ delete(reg.Accounts, handle)
204204+205205+ if reg.Active == handle {
206206+ reg.Active = ""
207207+ if len(reg.Accounts) == 1 {
208208+ for h := range reg.Accounts {
209209+ reg.Active = h
210210+ }
211211+ }
212212+ }
213213+214214+ // Clean up empty registries
215215+ if len(reg.Accounts) == 0 {
216216+ delete(c.Registries, registryURL)
217217+ }
218218+}
219219+220220+// getUpdateCheckCachePath returns the path to the update check cache file
221221+func getUpdateCheckCachePath() string {
222222+ homeDir, err := os.UserHomeDir()
223223+ if err != nil {
224224+ return ""
225225+ }
226226+ return fmt.Sprintf("%s/.atcr/update-check.json", homeDir)
227227+}
228228+229229+// loadUpdateCheckCache loads the update check cache from disk
230230+func loadUpdateCheckCache() *UpdateCheckCache {
231231+ path := getUpdateCheckCachePath()
232232+ if path == "" {
233233+ return nil
234234+ }
235235+236236+ data, err := os.ReadFile(path)
237237+ if err != nil {
238238+ return nil
239239+ }
240240+241241+ var cache UpdateCheckCache
242242+ if err := json.Unmarshal(data, &cache); err != nil {
243243+ return nil
244244+ }
245245+246246+ return &cache
247247+}
248248+249249+// saveUpdateCheckCache saves the update check cache to disk
250250+func saveUpdateCheckCache(cache *UpdateCheckCache) {
251251+ path := getUpdateCheckCachePath()
252252+ if path == "" {
253253+ return
254254+ }
255255+256256+ data, err := json.MarshalIndent(cache, "", " ")
257257+ if err != nil {
258258+ return
259259+ }
260260+261261+ os.WriteFile(path, data, 0600) //nolint:errcheck
262262+}
+123
cmd/credential-helper/detect.go
···11+package main
22+33+import (
44+ "os"
55+ "strings"
66+)
77+88+// ImageRef is a parsed container image reference
99+type ImageRef struct {
1010+ Host string
1111+ Identity string
1212+ Repo string
1313+ Tag string
1414+ Raw string
1515+}
1616+1717+// detectImageRef walks the process tree looking for an image reference
1818+// that matches the given registry host. It starts from the parent process
1919+// and walks up to 5 ancestors to handle wrapper scripts (make, bash -c, etc.).
2020+//
2121+// Returns nil if no matching image reference is found — callers should
2222+// fall back to the active account.
2323+func detectImageRef(registryHost string) *ImageRef {
2424+ // Normalize the registry host for matching
2525+ matchHost := strings.TrimPrefix(registryHost, "https://")
2626+ matchHost = strings.TrimPrefix(matchHost, "http://")
2727+ matchHost = strings.TrimSuffix(matchHost, "/")
2828+2929+ pid := os.Getppid()
3030+ for depth := 0; depth < 5; depth++ {
3131+ args, err := getProcessArgs(pid)
3232+ if err != nil {
3333+ break
3434+ }
3535+3636+ for _, arg := range args {
3737+ if ref := parseImageRef(arg, matchHost); ref != nil {
3838+ return ref
3939+ }
4040+ }
4141+4242+ ppid, err := getParentPID(pid)
4343+ if err != nil || ppid == pid || ppid <= 1 {
4444+ break
4545+ }
4646+ pid = ppid
4747+ }
4848+4949+ return nil
5050+}
5151+5252+// parseImageRef tries to parse a string as a container image reference.
5353+// Expected format: host/identity/repo:tag or host/identity/repo
5454+//
5555+// Handles:
5656+// - docker:// and oci:// transport prefixes (skopeo)
5757+// - Flags (- prefix), paths (/ or . prefix), shell artifacts (|, &, ;)
5858+// - Optional tag (defaults to "latest")
5959+// - Host must look like a domain (contains ., or is localhost, or has :port)
6060+// - If matchHost is non-empty, only returns refs matching that host
6161+func parseImageRef(s string, matchHost string) *ImageRef {
6262+ // Skip flags, absolute paths, relative paths
6363+ if strings.HasPrefix(s, "-") || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") {
6464+ return nil
6565+ }
6666+6767+ // Strip docker:// or oci:// transport prefixes (skopeo)
6868+ s = strings.TrimPrefix(s, "docker://")
6969+ s = strings.TrimPrefix(s, "oci://")
7070+7171+ // Skip other transport schemes
7272+ if strings.Contains(s, "://") {
7373+ return nil
7474+ }
7575+ // Must contain at least one slash
7676+ if !strings.Contains(s, "/") {
7777+ return nil
7878+ }
7979+ // Skip things that look like shell commands
8080+ if strings.ContainsAny(s, " |&;") {
8181+ return nil
8282+ }
8383+8484+ // Split off tag
8585+ tag := "latest"
8686+ refPart := s
8787+ if atIdx := strings.LastIndex(s, ":"); atIdx != -1 {
8888+ lastSlash := strings.LastIndex(s, "/")
8989+ if atIdx > lastSlash {
9090+ tag = s[atIdx+1:]
9191+ refPart = s[:atIdx]
9292+ }
9393+ }
9494+9595+ parts := strings.Split(refPart, "/")
9696+9797+ // ATCR pattern requires host/identity/repo (3+ parts)
9898+ if len(parts) < 3 {
9999+ return nil
100100+ }
101101+102102+ host := parts[0]
103103+ identity := parts[1]
104104+ repo := strings.Join(parts[2:], "/")
105105+106106+ // Host must look like a domain
107107+ if !strings.Contains(host, ".") && host != "localhost" && !strings.Contains(host, ":") {
108108+ return nil
109109+ }
110110+111111+ // If a specific host was requested, enforce it
112112+ if matchHost != "" && host != matchHost {
113113+ return nil
114114+ }
115115+116116+ return &ImageRef{
117117+ Host: host,
118118+ Identity: identity,
119119+ Repo: repo,
120120+ Tag: tag,
121121+ Raw: s,
122122+ }
123123+}
+173
cmd/credential-helper/device_auth.go
···11+package main
22+33+import (
44+ "bytes"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net/http"
99+ "os"
1010+ "time"
1111+)
1212+1313+// Device authorization API types
1414+1515+type DeviceCodeRequest struct {
1616+ DeviceName string `json:"device_name"`
1717+}
1818+1919+type DeviceCodeResponse struct {
2020+ DeviceCode string `json:"device_code"`
2121+ UserCode string `json:"user_code"`
2222+ VerificationURI string `json:"verification_uri"`
2323+ ExpiresIn int `json:"expires_in"`
2424+ Interval int `json:"interval"`
2525+}
2626+2727+type DeviceTokenRequest struct {
2828+ DeviceCode string `json:"device_code"`
2929+}
3030+3131+type DeviceTokenResponse struct {
3232+ DeviceSecret string `json:"device_secret,omitempty"`
3333+ Handle string `json:"handle,omitempty"`
3434+ DID string `json:"did,omitempty"`
3535+ Error string `json:"error,omitempty"`
3636+}
3737+3838+// AuthErrorResponse is the JSON error response from /auth/token
3939+type AuthErrorResponse struct {
4040+ Error string `json:"error"`
4141+ Message string `json:"message"`
4242+ LoginURL string `json:"login_url,omitempty"`
4343+}
4444+4545+// ValidationResult represents the result of credential validation
4646+type ValidationResult struct {
4747+ Valid bool
4848+ OAuthSessionExpired bool
4949+ LoginURL string
5050+}
5151+5252+// requestDeviceCode requests a device code from the AppView.
5353+// Returns the code response and resolved AppView URL.
5454+// Does not print anything — the caller controls UX.
5555+func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) {
5656+ appViewURL := buildAppViewURL(serverURL)
5757+ deviceName := hostname()
5858+5959+ reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
6060+ resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
6161+ if err != nil {
6262+ return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err)
6363+ }
6464+ defer resp.Body.Close()
6565+6666+ if resp.StatusCode != http.StatusOK {
6767+ body, _ := io.ReadAll(resp.Body)
6868+ return nil, appViewURL, fmt.Errorf("device code request failed: %s", string(body))
6969+ }
7070+7171+ var codeResp DeviceCodeResponse
7272+ if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
7373+ return nil, appViewURL, fmt.Errorf("failed to decode device code response: %w", err)
7474+ }
7575+7676+ return &codeResp, appViewURL, nil
7777+}
7878+7979+// pollDeviceToken polls the token endpoint until authorization completes.
8080+// Does not print anything — the caller controls UX.
8181+// Returns the account on success, or an error on timeout/failure.
8282+func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account, error) {
8383+ pollInterval := time.Duration(codeResp.Interval) * time.Second
8484+ timeout := time.Duration(codeResp.ExpiresIn) * time.Second
8585+ deadline := time.Now().Add(timeout)
8686+8787+ for time.Now().Before(deadline) {
8888+ time.Sleep(pollInterval)
8989+9090+ tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
9191+ tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
9292+ if err != nil {
9393+ continue
9494+ }
9595+9696+ var tokenResult DeviceTokenResponse
9797+ if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil {
9898+ tokenResp.Body.Close()
9999+ continue
100100+ }
101101+ tokenResp.Body.Close()
102102+103103+ if tokenResult.Error == "authorization_pending" {
104104+ continue
105105+ }
106106+107107+ if tokenResult.Error != "" {
108108+ return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
109109+ }
110110+111111+ return &Account{
112112+ Handle: tokenResult.Handle,
113113+ DID: tokenResult.DID,
114114+ DeviceSecret: tokenResult.DeviceSecret,
115115+ }, nil
116116+ }
117117+118118+ return nil, fmt.Errorf("authorization timed out")
119119+}
120120+121121+// validateCredentials checks if the credentials are still valid by making a test request
122122+func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
123123+ client := &http.Client{
124124+ Timeout: 5 * time.Second,
125125+ }
126126+127127+ tokenURL := appViewURL + "/auth/token?service=" + appViewURL
128128+129129+ req, err := http.NewRequest("GET", tokenURL, nil)
130130+ if err != nil {
131131+ return ValidationResult{Valid: false}
132132+ }
133133+134134+ req.SetBasicAuth(handle, deviceSecret)
135135+136136+ resp, err := client.Do(req)
137137+ if err != nil {
138138+ // Network error — assume credentials are valid but server unreachable
139139+ return ValidationResult{Valid: true}
140140+ }
141141+ defer resp.Body.Close()
142142+143143+ if resp.StatusCode == http.StatusOK {
144144+ return ValidationResult{Valid: true}
145145+ }
146146+147147+ if resp.StatusCode == http.StatusUnauthorized {
148148+ body, err := io.ReadAll(resp.Body)
149149+ if err == nil {
150150+ var authErr AuthErrorResponse
151151+ if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
152152+ return ValidationResult{
153153+ Valid: false,
154154+ OAuthSessionExpired: true,
155155+ LoginURL: authErr.LoginURL,
156156+ }
157157+ }
158158+ }
159159+ return ValidationResult{Valid: false}
160160+ }
161161+162162+ // Any other error = assume valid (don't re-auth on server issues)
163163+ return ValidationResult{Valid: true}
164164+}
165165+166166+// hostname returns the machine hostname, or a fallback.
167167+func hostname() string {
168168+ name, err := os.Hostname()
169169+ if err != nil {
170170+ return "Unknown Device"
171171+ }
172172+ return name
173173+}
+195
cmd/credential-helper/helpers.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "net"
77+ "os"
88+ "os/exec"
99+ "path/filepath"
1010+ "runtime"
1111+ "strings"
1212+1313+ "github.com/charmbracelet/lipgloss"
1414+)
1515+1616+// Status message styles (matching gh CLI conventions)
1717+var (
1818+ successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
1919+ warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
2020+ infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan
2121+ boldStyle = lipgloss.NewStyle().Bold(true)
2222+)
2323+2424+// logSuccess prints a green ✓ prefixed message to stderr
2525+func logSuccess(format string, a ...any) {
2626+ fmt.Fprintf(os.Stderr, "%s %s\n", successStyle.Render("✓"), fmt.Sprintf(format, a...))
2727+}
2828+2929+// logWarning prints a yellow ! prefixed message to stderr
3030+func logWarning(format string, a ...any) {
3131+ fmt.Fprintf(os.Stderr, "%s %s\n", warningStyle.Render("!"), fmt.Sprintf(format, a...))
3232+}
3333+3434+// logInfo prints a cyan - prefixed message to stderr
3535+func logInfo(format string, a ...any) {
3636+ fmt.Fprintf(os.Stderr, "%s %s\n", infoStyle.Render("-"), fmt.Sprintf(format, a...))
3737+}
3838+3939+// logInfof prints a cyan - prefixed message to stderr without a trailing newline
4040+func logInfof(format string, a ...any) {
4141+ fmt.Fprintf(os.Stderr, "%s %s", infoStyle.Render("-"), fmt.Sprintf(format, a...))
4242+}
4343+4444+// bold renders text in bold
4545+func bold(s string) string {
4646+ return boldStyle.Render(s)
4747+}
4848+4949+// DockerDaemonConfig represents Docker's daemon.json configuration
5050+type DockerDaemonConfig struct {
5151+ InsecureRegistries []string `json:"insecure-registries"`
5252+}
5353+5454+// openBrowser opens the specified URL in the default browser
5555+func openBrowser(url string) error {
5656+ var cmd *exec.Cmd
5757+5858+ switch runtime.GOOS {
5959+ case "linux":
6060+ cmd = exec.Command("xdg-open", url)
6161+ case "darwin":
6262+ cmd = exec.Command("open", url)
6363+ case "windows":
6464+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
6565+ default:
6666+ return fmt.Errorf("unsupported platform")
6767+ }
6868+6969+ return cmd.Start()
7070+}
7171+7272+// buildAppViewURL constructs the AppView URL with the appropriate protocol
7373+func buildAppViewURL(serverURL string) string {
7474+ // If serverURL already has a scheme, use it as-is
7575+ if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") {
7676+ return serverURL
7777+ }
7878+7979+ // Determine protocol based on Docker configuration and heuristics
8080+ if isInsecureRegistry(serverURL) {
8181+ return "http://" + serverURL
8282+ }
8383+8484+ // Default to HTTPS (mirrors Docker's default behavior)
8585+ return "https://" + serverURL
8686+}
8787+8888+// isInsecureRegistry checks if a registry should use HTTP instead of HTTPS
8989+func isInsecureRegistry(serverURL string) bool {
9090+ // Check Docker's insecure-registries configuration
9191+ insecureRegistries := getDockerInsecureRegistries()
9292+ for _, reg := range insecureRegistries {
9393+ if reg == serverURL || reg == stripPort(serverURL) {
9494+ return true
9595+ }
9696+ }
9797+9898+ // Fallback heuristics: localhost and private IPs
9999+ host := stripPort(serverURL)
100100+101101+ if host == "localhost" || host == "127.0.0.1" || host == "::1" {
102102+ return true
103103+ }
104104+105105+ if ip := net.ParseIP(host); ip != nil {
106106+ if ip.IsLoopback() || ip.IsPrivate() {
107107+ return true
108108+ }
109109+ }
110110+111111+ return false
112112+}
113113+114114+// getDockerInsecureRegistries reads Docker's insecure-registries configuration
115115+func getDockerInsecureRegistries() []string {
116116+ var paths []string
117117+118118+ switch runtime.GOOS {
119119+ case "windows":
120120+ programData := os.Getenv("ProgramData")
121121+ if programData != "" {
122122+ paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json"))
123123+ }
124124+ default:
125125+ paths = append(paths, "/etc/docker/daemon.json")
126126+ if homeDir, err := os.UserHomeDir(); err == nil {
127127+ paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json"))
128128+ }
129129+ }
130130+131131+ for _, path := range paths {
132132+ if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 {
133133+ return config.InsecureRegistries
134134+ }
135135+ }
136136+137137+ return nil
138138+}
139139+140140+// readDockerDaemonConfig reads and parses a Docker daemon.json file
141141+func readDockerDaemonConfig(path string) *DockerDaemonConfig {
142142+ data, err := os.ReadFile(path)
143143+ if err != nil {
144144+ return nil
145145+ }
146146+147147+ var config DockerDaemonConfig
148148+ if err := json.Unmarshal(data, &config); err != nil {
149149+ return nil
150150+ }
151151+152152+ return &config
153153+}
154154+155155+// stripPort removes the port from a host:port string
156156+func stripPort(hostPort string) string {
157157+ if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
158158+ if strings.Count(hostPort, ":") > 1 {
159159+ return hostPort
160160+ }
161161+ return hostPort[:colonIdx]
162162+ }
163163+ return hostPort
164164+}
165165+166166+// isTerminal checks if the file is a terminal
167167+func isTerminal(f *os.File) bool {
168168+ stat, err := f.Stat()
169169+ if err != nil {
170170+ return false
171171+ }
172172+ return (stat.Mode() & os.ModeCharDevice) != 0
173173+}
174174+175175+// getConfigDir returns the path to the .atcr config directory, creating it if needed
176176+func getConfigDir() string {
177177+ homeDir, err := os.UserHomeDir()
178178+ if err != nil {
179179+ fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
180180+ os.Exit(1)
181181+ }
182182+183183+ atcrDir := filepath.Join(homeDir, ".atcr")
184184+ if err := os.MkdirAll(atcrDir, 0700); err != nil {
185185+ fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
186186+ os.Exit(1)
187187+ }
188188+189189+ return atcrDir
190190+}
191191+192192+// getConfigPath returns the path to the device configuration file
193193+func getConfigPath() string {
194194+ return filepath.Join(getConfigDir(), "device.json")
195195+}
+28-1044
cmd/credential-helper/main.go
···11package main
2233import (
44- "bytes"
55- "encoding/json"
64 "fmt"
77- "io"
88- "net"
99- "net/http"
105 "os"
1111- "os/exec"
1212- "path/filepath"
1313- "runtime"
1414- "strconv"
1515- "strings"
166 "time"
1717-)
1871919-// DeviceConfig represents the stored device configuration
2020-type DeviceConfig struct {
2121- Handle string `json:"handle"`
2222- DeviceSecret string `json:"device_secret"`
2323- AppViewURL string `json:"appview_url"`
2424-}
2525-2626-// DeviceCredentials stores multiple device configurations keyed by AppView URL
2727-type DeviceCredentials struct {
2828- Credentials map[string]DeviceConfig `json:"credentials"`
2929-}
3030-3131-// DockerDaemonConfig represents Docker's daemon.json configuration
3232-type DockerDaemonConfig struct {
3333- InsecureRegistries []string `json:"insecure-registries"`
3434-}
3535-3636-// Docker credential helper protocol
3737-// https://github.com/docker/docker-credential-helpers
3838-3939-// Credentials represents docker credentials
4040-type Credentials struct {
4141- ServerURL string `json:"ServerURL,omitempty"`
4242- Username string `json:"Username,omitempty"`
4343- Secret string `json:"Secret,omitempty"`
4444-}
4545-4646-// Device authorization API types
4747-4848-type DeviceCodeRequest struct {
4949- DeviceName string `json:"device_name"`
5050-}
5151-5252-type DeviceCodeResponse struct {
5353- DeviceCode string `json:"device_code"`
5454- UserCode string `json:"user_code"`
5555- VerificationURI string `json:"verification_uri"`
5656- ExpiresIn int `json:"expires_in"`
5757- Interval int `json:"interval"`
5858-}
5959-6060-type DeviceTokenRequest struct {
6161- DeviceCode string `json:"device_code"`
6262-}
6363-6464-type DeviceTokenResponse struct {
6565- DeviceSecret string `json:"device_secret,omitempty"`
6666- Handle string `json:"handle,omitempty"`
6767- DID string `json:"did,omitempty"`
6868- Error string `json:"error,omitempty"`
6969-}
7070-7171-// AuthErrorResponse is the JSON error response from /auth/token
7272-type AuthErrorResponse struct {
7373- Error string `json:"error"`
7474- Message string `json:"message"`
7575- LoginURL string `json:"login_url,omitempty"`
7676-}
7777-7878-// ValidationResult represents the result of credential validation
7979-type ValidationResult struct {
8080- Valid bool
8181- OAuthSessionExpired bool
8282- LoginURL string
8383-}
8484-8585-// VersionAPIResponse is the response from /api/credential-helper/version
8686-type VersionAPIResponse struct {
8787- Latest string `json:"latest"`
8888- DownloadURLs map[string]string `json:"download_urls"`
8989- Checksums map[string]string `json:"checksums"`
9090- ReleaseNotes string `json:"release_notes,omitempty"`
9191-}
9292-9393-// UpdateCheckCache stores the last update check result
9494-type UpdateCheckCache struct {
9595- CheckedAt time.Time `json:"checked_at"`
9696- Latest string `json:"latest"`
9797- Current string `json:"current"`
9898-}
88+ "github.com/spf13/cobra"
99+)
991010011var (
10112 version = "dev"
···10617 updateCheckCacheTTL = 24 * time.Hour
10718)
10819109109-func main() {
110110- if len(os.Args) < 2 {
111111- fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version|update>\n")
112112- os.Exit(1)
113113- }
114114-115115- command := os.Args[1]
116116-117117- switch command {
118118- case "get":
119119- handleGet()
120120- case "store":
121121- handleStore()
122122- case "erase":
123123- handleErase()
124124- case "version":
125125- fmt.Printf("docker-credential-atcr %s (commit: %s, built: %s)\n", version, commit, date)
126126- case "update":
127127- checkOnly := len(os.Args) > 2 && os.Args[2] == "--check"
128128- handleUpdate(checkOnly)
129129- default:
130130- fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
131131- os.Exit(1)
132132- }
133133-}
134134-135135-// handleGet retrieves credentials for the given server
136136-func handleGet() {
137137- // Docker sends the server URL as a plain string on stdin (not JSON)
138138- var serverURL string
139139- if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
140140- fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
141141- os.Exit(1)
142142- }
143143-144144- // Build AppView URL to use as lookup key
145145- appViewURL := buildAppViewURL(serverURL)
146146-147147- // Load all device credentials
148148- configPath := getConfigPath()
149149- allCreds, err := loadDeviceCredentials(configPath)
150150- if err != nil {
151151- // No credentials file exists yet
152152- allCreds = &DeviceCredentials{
153153- Credentials: make(map[string]DeviceConfig),
154154- }
155155- }
156156-157157- // Look up device config for this specific AppView URL
158158- deviceConfig, found := getDeviceConfig(allCreds, appViewURL)
159159-160160- // If credentials exist, validate them
161161- if found && deviceConfig.DeviceSecret != "" {
162162- result := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
163163- if !result.Valid {
164164- if result.OAuthSessionExpired {
165165- // OAuth session expired - need to re-authenticate via browser
166166- // Device secret is still valid, just need to restore OAuth session
167167- fmt.Fprintf(os.Stderr, "OAuth session expired. Opening browser to re-authenticate...\n")
168168-169169- loginURL := result.LoginURL
170170- if loginURL == "" {
171171- loginURL = appViewURL + "/auth/oauth/login"
172172- }
173173-174174- // Try to open browser
175175- if err := openBrowser(loginURL); err != nil {
176176- fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
177177- fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
178178- } else {
179179- fmt.Fprintf(os.Stderr, "Please complete authentication in your browser.\n")
180180- }
181181-182182- // Wait for user to complete OAuth flow, then retry
183183- fmt.Fprintf(os.Stderr, "Waiting for authentication")
184184- for range 60 { // Wait up to 2 minutes
185185- time.Sleep(2 * time.Second)
186186- fmt.Fprintf(os.Stderr, ".")
187187-188188- // Retry validation
189189- retryResult := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret)
190190- if retryResult.Valid {
191191- fmt.Fprintf(os.Stderr, "\n✓ Re-authenticated successfully!\n")
192192- goto credentialsValid
193193- }
194194- }
195195- fmt.Fprintf(os.Stderr, "\nAuthentication timed out. Please try again.\n")
196196- os.Exit(1)
197197- }
198198-199199- // Generic auth failure - delete credentials and re-authorize
200200- fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL)
201201- // Delete the invalid credentials
202202- delete(allCreds.Credentials, appViewURL)
203203- if err := saveDeviceCredentials(configPath, allCreds); err != nil {
204204- fmt.Fprintf(os.Stderr, "Warning: failed to save updated credentials: %v\n", err)
205205- }
206206- // Mark as not found so we re-authorize below
207207- found = false
208208- }
209209- }
210210-credentialsValid:
211211-212212- if !found || deviceConfig.DeviceSecret == "" {
213213- // No credentials for this AppView
214214- // Check if we should attempt interactive authorization
215215- // We only do this if:
216216- // 1. ATCR_AUTO_AUTH environment variable is set to "1", OR
217217- // 2. We're in an interactive terminal (stderr is a terminal)
218218- shouldAutoAuth := os.Getenv("ATCR_AUTO_AUTH") == "1" || isTerminal(os.Stderr)
219219-220220- if !shouldAutoAuth {
221221- fmt.Fprintf(os.Stderr, "No valid credentials found for %s\n", appViewURL)
222222- fmt.Fprintf(os.Stderr, "\nTo authenticate, run:\n")
223223- fmt.Fprintf(os.Stderr, " export ATCR_AUTO_AUTH=1\n")
224224- fmt.Fprintf(os.Stderr, " docker push %s/<user>/<image>:<tag>\n", serverURL)
225225- fmt.Fprintf(os.Stderr, "\nThis will trigger device authorization in your browser.\n")
226226- os.Exit(1)
227227- }
228228-229229- // Auto-auth enabled - trigger device authorization
230230- fmt.Fprintf(os.Stderr, "Starting device authorization for %s...\n", appViewURL)
231231-232232- newConfig, err := authorizeDevice(serverURL)
233233- if err != nil {
234234- fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err)
235235- fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL)
236236- os.Exit(1)
237237- }
238238-239239- // Save device configuration
240240- if err := saveDeviceConfig(configPath, newConfig); err != nil {
241241- fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err)
242242- os.Exit(1)
243243- }
244244-245245- fmt.Fprintf(os.Stderr, "✓ Device authorized successfully for %s!\n", appViewURL)
246246- deviceConfig = newConfig
247247- }
248248-249249- // Check for updates (non-blocking due to 24h cache)
250250- checkAndNotifyUpdate(appViewURL)
251251-252252- // Return credentials for Docker
253253- creds := Credentials{
254254- ServerURL: serverURL,
255255- Username: deviceConfig.Handle,
256256- Secret: deviceConfig.DeviceSecret,
257257- }
258258-259259- if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
260260- fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err)
261261- os.Exit(1)
262262- }
263263-}
264264-265265-// handleStore stores credentials (Docker calls this after login)
266266-func handleStore() {
267267- var creds Credentials
268268- if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
269269- fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err)
270270- os.Exit(1)
271271- }
272272-273273- // This is a no-op for the device auth flow
274274- // Users should use the automatic device authorization, not docker login
275275- // If they use docker login with app-password, that goes through /auth/token directly
276276-}
277277-278278-// handleErase removes stored credentials for a specific AppView
279279-func handleErase() {
280280- // Docker sends the server URL as a plain string on stdin (not JSON)
281281- var serverURL string
282282- if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
283283- fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
284284- os.Exit(1)
285285- }
286286-287287- // Build AppView URL to use as lookup key
288288- appViewURL := buildAppViewURL(serverURL)
289289-290290- // Load all device credentials
291291- configPath := getConfigPath()
292292- allCreds, err := loadDeviceCredentials(configPath)
293293- if err != nil {
294294- // No credentials file exists, nothing to erase
295295- return
296296- }
297297-298298- // Remove the specific AppView URL's credentials
299299- delete(allCreds.Credentials, appViewURL)
300300-301301- // If no credentials remain, remove the file entirely
302302- if len(allCreds.Credentials) == 0 {
303303- if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
304304- fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err)
305305- os.Exit(1)
306306- }
307307- return
308308- }
309309-310310- // Otherwise, save the updated credentials
311311- if err := saveDeviceCredentials(configPath, allCreds); err != nil {
312312- fmt.Fprintf(os.Stderr, "Error saving device config: %v\n", err)
313313- os.Exit(1)
314314- }
315315-}
316316-317317-// authorizeDevice performs the device authorization flow
318318-func authorizeDevice(serverURL string) (*DeviceConfig, error) {
319319- appViewURL := buildAppViewURL(serverURL)
320320-321321- // Get device name (hostname)
322322- deviceName, err := os.Hostname()
323323- if err != nil {
324324- deviceName = "Unknown Device"
325325- }
326326-327327- // 1. Request device code
328328- fmt.Fprintf(os.Stderr, "Requesting device authorization...\n")
329329-330330- reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
331331- resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
332332- if err != nil {
333333- return nil, fmt.Errorf("failed to request device code: %w", err)
334334- }
335335- defer resp.Body.Close()
336336-337337- if resp.StatusCode != http.StatusOK {
338338- body, _ := io.ReadAll(resp.Body)
339339- return nil, fmt.Errorf("device code request failed: %s", string(body))
340340- }
341341-342342- var codeResp DeviceCodeResponse
343343- if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
344344- return nil, fmt.Errorf("failed to decode device code response: %w", err)
345345- }
346346-347347- // 2. Display authorization URL and user code
348348- verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode
349349-350350- fmt.Fprintf(os.Stderr, "\n╔════════════════════════════════════════════════════════════════╗\n")
351351- fmt.Fprintf(os.Stderr, "║ Device Authorization Required ║\n")
352352- fmt.Fprintf(os.Stderr, "╚════════════════════════════════════════════════════════════════╝\n\n")
353353- fmt.Fprintf(os.Stderr, "Visit this URL in your browser:\n")
354354- fmt.Fprintf(os.Stderr, " %s\n\n", verificationURL)
355355- fmt.Fprintf(os.Stderr, "Your code: %s\n\n", codeResp.UserCode)
356356-357357- // Try to open browser (may fail on headless systems)
358358- if err := openBrowser(verificationURL); err == nil {
359359- fmt.Fprintf(os.Stderr, "Opening browser...\n\n")
360360- } else {
361361- fmt.Fprintf(os.Stderr, "Could not open browser automatically (%v)\n", err)
362362- fmt.Fprintf(os.Stderr, "Please open the URL above manually.\n\n")
363363- }
364364-365365- fmt.Fprintf(os.Stderr, "Waiting for authorization")
366366-367367- // 3. Poll for authorization completion
368368- pollInterval := time.Duration(codeResp.Interval) * time.Second
369369- timeout := time.Duration(codeResp.ExpiresIn) * time.Second
370370- deadline := time.Now().Add(timeout)
371371-372372- dots := 0
373373- for time.Now().Before(deadline) {
374374- time.Sleep(pollInterval)
375375-376376- // Show progress dots
377377- dots = (dots + 1) % 4
378378- fmt.Fprintf(os.Stderr, "\rWaiting for authorization%s ", strings.Repeat(".", dots))
379379-380380- // Poll token endpoint
381381- tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
382382- tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
383383- if err != nil {
384384- fmt.Fprintf(os.Stderr, "\nPoll failed: %v\n", err)
385385- continue
386386- }
387387-388388- var tokenResult DeviceTokenResponse
389389- if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil {
390390- fmt.Fprintf(os.Stderr, "\nFailed to decode response: %v\n", err)
391391- tokenResp.Body.Close()
392392- continue
393393- }
394394- tokenResp.Body.Close()
395395-396396- if tokenResult.Error == "authorization_pending" {
397397- // Still waiting
398398- continue
399399- }
400400-401401- if tokenResult.Error != "" {
402402- fmt.Fprintf(os.Stderr, "\n")
403403- return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
404404- }
405405-406406- // Success!
407407- fmt.Fprintf(os.Stderr, "\n")
408408- return &DeviceConfig{
409409- Handle: tokenResult.Handle,
410410- DeviceSecret: tokenResult.DeviceSecret,
411411- AppViewURL: appViewURL,
412412- }, nil
413413- }
414414-415415- fmt.Fprintf(os.Stderr, "\n")
416416- return nil, fmt.Errorf("authorization timeout")
417417-}
418418-419419-// getConfigPath returns the path to the device configuration file
420420-func getConfigPath() string {
421421- homeDir, err := os.UserHomeDir()
422422- if err != nil {
423423- fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
424424- os.Exit(1)
425425- }
426426-427427- atcrDir := filepath.Join(homeDir, ".atcr")
428428- if err := os.MkdirAll(atcrDir, 0700); err != nil {
429429- fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
430430- os.Exit(1)
431431- }
432432-433433- return filepath.Join(atcrDir, "device.json")
434434-}
435435-436436-// loadDeviceCredentials loads all device credentials from disk
437437-func loadDeviceCredentials(path string) (*DeviceCredentials, error) {
438438- data, err := os.ReadFile(path)
439439- if err != nil {
440440- return nil, err
441441- }
442442-443443- // Try to unmarshal as new format (map of credentials)
444444- var creds DeviceCredentials
445445- if err := json.Unmarshal(data, &creds); err == nil && creds.Credentials != nil {
446446- return &creds, nil
447447- }
448448-449449- // Backward compatibility: Try to unmarshal as old format (single config)
450450- var oldConfig DeviceConfig
451451- if err := json.Unmarshal(data, &oldConfig); err == nil && oldConfig.DeviceSecret != "" {
452452- // Migrate old format to new format
453453- creds = DeviceCredentials{
454454- Credentials: map[string]DeviceConfig{
455455- oldConfig.AppViewURL: oldConfig,
456456- },
457457- }
458458- return &creds, nil
459459- }
460460-461461- return nil, fmt.Errorf("invalid device credentials format")
462462-}
463463-464464-// getDeviceConfig retrieves a specific device config for an AppView URL
465465-func getDeviceConfig(creds *DeviceCredentials, appViewURL string) (*DeviceConfig, bool) {
466466- if creds == nil || creds.Credentials == nil {
467467- return nil, false
468468- }
469469- config, found := creds.Credentials[appViewURL]
470470- return &config, found
471471-}
472472-473473-// saveDeviceCredentials saves all device credentials to disk
474474-func saveDeviceCredentials(path string, creds *DeviceCredentials) error {
475475- data, err := json.MarshalIndent(creds, "", " ")
476476- if err != nil {
477477- return err
478478- }
479479-480480- return os.WriteFile(path, data, 0600)
481481-}
482482-483483-// saveDeviceConfig saves a single device config by adding/updating it in the credentials map
484484-func saveDeviceConfig(path string, config *DeviceConfig) error {
485485- // Load existing credentials (or create new)
486486- creds, err := loadDeviceCredentials(path)
487487- if err != nil {
488488- // Create new credentials structure
489489- creds = &DeviceCredentials{
490490- Credentials: make(map[string]DeviceConfig),
491491- }
492492- }
493493-494494- // Add or update the config for this AppView URL
495495- creds.Credentials[config.AppViewURL] = *config
496496-497497- // Save back to disk
498498- return saveDeviceCredentials(path, creds)
499499-}
500500-501501-// openBrowser opens the specified URL in the default browser
502502-func openBrowser(url string) error {
503503- var cmd *exec.Cmd
504504-505505- switch runtime.GOOS {
506506- case "linux":
507507- cmd = exec.Command("xdg-open", url)
508508- case "darwin":
509509- cmd = exec.Command("open", url)
510510- case "windows":
511511- cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
512512- default:
513513- return fmt.Errorf("unsupported platform")
514514- }
515515-516516- return cmd.Start()
517517-}
518518-519519-// buildAppViewURL constructs the AppView URL with the appropriate protocol
520520-func buildAppViewURL(serverURL string) string {
521521- // If serverURL already has a scheme, use it as-is
522522- if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") {
523523- return serverURL
524524- }
525525-526526- // Determine protocol based on Docker configuration and heuristics
527527- if isInsecureRegistry(serverURL) {
528528- return "http://" + serverURL
529529- }
530530-531531- // Default to HTTPS (mirrors Docker's default behavior)
532532- return "https://" + serverURL
533533-}
534534-535535-// isInsecureRegistry checks if a registry should use HTTP instead of HTTPS
536536-func isInsecureRegistry(serverURL string) bool {
537537- // Check Docker's insecure-registries configuration
538538- insecureRegistries := getDockerInsecureRegistries()
539539- for _, reg := range insecureRegistries {
540540- // Match exact serverURL or just the host part
541541- if reg == serverURL || reg == stripPort(serverURL) {
542542- return true
543543- }
544544- }
545545-546546- // Fallback heuristics: localhost and private IPs
547547- host := stripPort(serverURL)
548548-549549- // Check for localhost variants
550550- if host == "localhost" || host == "127.0.0.1" || host == "::1" {
551551- return true
552552- }
553553-554554- // Check if it's a private IP address
555555- if ip := net.ParseIP(host); ip != nil {
556556- if ip.IsLoopback() || ip.IsPrivate() {
557557- return true
558558- }
559559- }
560560-561561- return false
562562-}
563563-564564-// getDockerInsecureRegistries reads Docker's insecure-registries configuration
565565-func getDockerInsecureRegistries() []string {
566566- var paths []string
567567-568568- // Common Docker daemon.json locations
569569- switch runtime.GOOS {
570570- case "windows":
571571- programData := os.Getenv("ProgramData")
572572- if programData != "" {
573573- paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json"))
574574- }
575575- default:
576576- // Linux and macOS
577577- paths = append(paths, "/etc/docker/daemon.json")
578578- if homeDir, err := os.UserHomeDir(); err == nil {
579579- // Rootless Docker location
580580- paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json"))
581581- }
582582- }
583583-584584- // Try each path
585585- for _, path := range paths {
586586- if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 {
587587- return config.InsecureRegistries
588588- }
589589- }
590590-591591- return nil
592592-}
593593-594594-// readDockerDaemonConfig reads and parses a Docker daemon.json file
595595-func readDockerDaemonConfig(path string) *DockerDaemonConfig {
596596- data, err := os.ReadFile(path)
597597- if err != nil {
598598- return nil
599599- }
600600-601601- var config DockerDaemonConfig
602602- if err := json.Unmarshal(data, &config); err != nil {
603603- return nil
604604- }
605605-606606- return &config
607607-}
608608-609609-// stripPort removes the port from a host:port string
610610-func stripPort(hostPort string) string {
611611- if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
612612- // Check if this is IPv6 (has multiple colons)
613613- if strings.Count(hostPort, ":") > 1 {
614614- // IPv6 address, don't strip
615615- return hostPort
616616- }
617617- return hostPort[:colonIdx]
618618- }
619619- return hostPort
620620-}
621621-622622-// isTerminal checks if the file is a terminal
623623-func isTerminal(f *os.File) bool {
624624- // Use file stat to check if it's a character device (terminal)
625625- stat, err := f.Stat()
626626- if err != nil {
627627- return false
628628- }
629629- // On Unix, terminals are character devices with mode & ModeCharDevice set
630630- return (stat.Mode() & os.ModeCharDevice) != 0
631631-}
632632-633633-// validateCredentials checks if the credentials are still valid by making a test request
634634-func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
635635- // Call /auth/token to validate device secret and get JWT
636636- // This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth
637637- client := &http.Client{
638638- Timeout: 5 * time.Second,
639639- }
640640-641641- // Build /auth/token URL with minimal scope (just access to /v2/)
642642- tokenURL := appViewURL + "/auth/token?service=" + appViewURL
643643-644644- req, err := http.NewRequest("GET", tokenURL, nil)
645645- if err != nil {
646646- return ValidationResult{Valid: false}
647647- }
648648-649649- // Set basic auth with device credentials
650650- req.SetBasicAuth(handle, deviceSecret)
651651-652652- resp, err := client.Do(req)
653653- if err != nil {
654654- // Network error - assume credentials are valid but server unreachable
655655- // Don't trigger re-auth on network issues
656656- return ValidationResult{Valid: true}
657657- }
658658- defer resp.Body.Close()
659659-660660- // 200 = valid credentials
661661- if resp.StatusCode == http.StatusOK {
662662- return ValidationResult{Valid: true}
663663- }
664664-665665- // 401 = check if it's OAuth session expired
666666- if resp.StatusCode == http.StatusUnauthorized {
667667- // Try to parse JSON error response
668668- body, err := io.ReadAll(resp.Body)
669669- if err == nil {
670670- var authErr AuthErrorResponse
671671- if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" {
672672- return ValidationResult{
673673- Valid: false,
674674- OAuthSessionExpired: true,
675675- LoginURL: authErr.LoginURL,
676676- }
677677- }
678678- }
679679- // Generic auth failure
680680- return ValidationResult{Valid: false}
681681- }
682682-683683- // Any other error = assume valid (don't re-auth on server issues)
684684- return ValidationResult{Valid: true}
685685-}
2020+// timeNow is a variable so tests can override it.
2121+var timeNow = time.Now
68622687687-// handleUpdate handles the update command
688688-func handleUpdate(checkOnly bool) {
689689- // Default API URL
690690- apiURL := "https://atcr.io/api/credential-helper/version"
2323+func main() {
2424+ rootCmd := &cobra.Command{
2525+ Use: "docker-credential-atcr",
2626+ Short: "ATCR container registry credential helper",
2727+ Long: `docker-credential-atcr manages authentication for ATCR-compatible container registries.
69128692692- // Try to get AppView URL from stored credentials
693693- configPath := getConfigPath()
694694- allCreds, err := loadDeviceCredentials(configPath)
695695- if err == nil && len(allCreds.Credentials) > 0 {
696696- // Use the first stored AppView URL
697697- for _, cred := range allCreds.Credentials {
698698- if cred.AppViewURL != "" {
699699- apiURL = cred.AppViewURL + "/api/credential-helper/version"
700700- break
701701- }
702702- }
2929+It implements the Docker credential helper protocol and provides commands
3030+for managing multiple accounts across multiple registries.`,
3131+ Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date),
3232+ SilenceUsage: true,
3333+ SilenceErrors: true,
70334 }
70435705705- versionInfo, err := fetchVersionInfo(apiURL)
706706- if err != nil {
707707- fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err)
708708- os.Exit(1)
709709- }
3636+ // Docker protocol commands (hidden — called by Docker, not users)
3737+ rootCmd.AddCommand(newGetCmd())
3838+ rootCmd.AddCommand(newStoreCmd())
3939+ rootCmd.AddCommand(newEraseCmd())
4040+ rootCmd.AddCommand(newListCmd())
71041711711- // Compare versions
712712- if !isNewerVersion(versionInfo.Latest, version) {
713713- fmt.Printf("You're already running the latest version (%s)\n", version)
714714- return
715715- }
4242+ // User-facing commands
4343+ rootCmd.AddCommand(newLoginCmd())
4444+ rootCmd.AddCommand(newLogoutCmd())
4545+ rootCmd.AddCommand(newStatusCmd())
4646+ rootCmd.AddCommand(newSwitchCmd())
4747+ rootCmd.AddCommand(newConfigureDockerCmd())
4848+ rootCmd.AddCommand(newUpdateCmd())
71649717717- fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version)
718718-719719- if checkOnly {
720720- return
721721- }
722722-723723- // Perform the update
724724- if err := performUpdate(versionInfo); err != nil {
725725- fmt.Fprintf(os.Stderr, "Update failed: %v\n", err)
5050+ if err := rootCmd.Execute(); err != nil {
5151+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
72652 os.Exit(1)
727727- }
728728-729729- fmt.Println("Update completed successfully!")
730730-}
731731-732732-// fetchVersionInfo fetches version info from the AppView API
733733-func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) {
734734- client := &http.Client{
735735- Timeout: 10 * time.Second,
736736- }
737737-738738- resp, err := client.Get(apiURL)
739739- if err != nil {
740740- return nil, fmt.Errorf("failed to fetch version info: %w", err)
741741- }
742742- defer resp.Body.Close()
743743-744744- if resp.StatusCode != http.StatusOK {
745745- return nil, fmt.Errorf("version API returned status %d", resp.StatusCode)
746746- }
747747-748748- var versionInfo VersionAPIResponse
749749- if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
750750- return nil, fmt.Errorf("failed to parse version info: %w", err)
751751- }
752752-753753- return &versionInfo, nil
754754-}
755755-756756-// isNewerVersion compares two version strings (simple semver comparison)
757757-// Returns true if newVersion is newer than currentVersion
758758-func isNewerVersion(newVersion, currentVersion string) bool {
759759- // Handle "dev" version
760760- if currentVersion == "dev" {
761761- return true
762762- }
763763-764764- // Normalize versions (strip 'v' prefix)
765765- newV := strings.TrimPrefix(newVersion, "v")
766766- curV := strings.TrimPrefix(currentVersion, "v")
767767-768768- // Split into parts
769769- newParts := strings.Split(newV, ".")
770770- curParts := strings.Split(curV, ".")
771771-772772- // Compare each part
773773- for i := range min(len(newParts), len(curParts)) {
774774- newNum := 0
775775- if parsed, err := strconv.Atoi(newParts[i]); err == nil {
776776- newNum = parsed
777777- }
778778- curNum := 0
779779- if parsed, err := strconv.Atoi(curParts[i]); err == nil {
780780- curNum = parsed
781781- }
782782-783783- if newNum > curNum {
784784- return true
785785- }
786786- if newNum < curNum {
787787- return false
788788- }
789789- }
790790-791791- // If new version has more parts (e.g., 1.0.1 vs 1.0), it's newer
792792- return len(newParts) > len(curParts)
793793-}
794794-795795-// getPlatformKey returns the platform key for the current OS/arch
796796-func getPlatformKey() string {
797797- os := runtime.GOOS
798798- arch := runtime.GOARCH
799799-800800- // Normalize arch names
801801- switch arch {
802802- case "amd64":
803803- arch = "amd64"
804804- case "arm64":
805805- arch = "arm64"
806806- }
807807-808808- return fmt.Sprintf("%s_%s", os, arch)
809809-}
810810-811811-// performUpdate downloads and installs the new version
812812-func performUpdate(versionInfo *VersionAPIResponse) error {
813813- platformKey := getPlatformKey()
814814-815815- downloadURL, ok := versionInfo.DownloadURLs[platformKey]
816816- if !ok {
817817- return fmt.Errorf("no download available for platform %s", platformKey)
818818- }
819819-820820- expectedChecksum := versionInfo.Checksums[platformKey]
821821-822822- fmt.Printf("Downloading update from %s...\n", downloadURL)
823823-824824- // Create temp directory
825825- tmpDir, err := os.MkdirTemp("", "atcr-update-")
826826- if err != nil {
827827- return fmt.Errorf("failed to create temp directory: %w", err)
828828- }
829829- defer os.RemoveAll(tmpDir)
830830-831831- // Download the archive
832832- archivePath := filepath.Join(tmpDir, "archive.tar.gz")
833833- if strings.HasSuffix(downloadURL, ".zip") {
834834- archivePath = filepath.Join(tmpDir, "archive.zip")
835835- }
836836-837837- if err := downloadFile(downloadURL, archivePath); err != nil {
838838- return fmt.Errorf("failed to download: %w", err)
839839- }
840840-841841- // Verify checksum if provided
842842- if expectedChecksum != "" {
843843- if err := verifyChecksum(archivePath, expectedChecksum); err != nil {
844844- return fmt.Errorf("checksum verification failed: %w", err)
845845- }
846846- fmt.Println("Checksum verified.")
847847- }
848848-849849- // Extract the binary
850850- binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
851851- if runtime.GOOS == "windows" {
852852- binaryPath += ".exe"
853853- }
854854-855855- if strings.HasSuffix(archivePath, ".zip") {
856856- if err := extractZip(archivePath, tmpDir); err != nil {
857857- return fmt.Errorf("failed to extract archive: %w", err)
858858- }
859859- } else {
860860- if err := extractTarGz(archivePath, tmpDir); err != nil {
861861- return fmt.Errorf("failed to extract archive: %w", err)
862862- }
863863- }
864864-865865- // Get the current executable path
866866- currentPath, err := os.Executable()
867867- if err != nil {
868868- return fmt.Errorf("failed to get current executable path: %w", err)
869869- }
870870- currentPath, err = filepath.EvalSymlinks(currentPath)
871871- if err != nil {
872872- return fmt.Errorf("failed to resolve symlinks: %w", err)
873873- }
874874-875875- // Verify the new binary works
876876- fmt.Println("Verifying new binary...")
877877- verifyCmd := exec.Command(binaryPath, "version")
878878- if output, err := verifyCmd.Output(); err != nil {
879879- return fmt.Errorf("new binary verification failed: %w", err)
880880- } else {
881881- fmt.Printf("New binary version: %s", string(output))
882882- }
883883-884884- // Backup current binary
885885- backupPath := currentPath + ".bak"
886886- if err := os.Rename(currentPath, backupPath); err != nil {
887887- return fmt.Errorf("failed to backup current binary: %w", err)
888888- }
889889-890890- // Install new binary
891891- if err := copyFile(binaryPath, currentPath); err != nil {
892892- // Try to restore backup
893893- if renameErr := os.Rename(backupPath, currentPath); renameErr != nil {
894894- fmt.Fprintf(os.Stderr, "Warning: failed to restore backup: %v\n", renameErr)
895895- }
896896- return fmt.Errorf("failed to install new binary: %w", err)
897897- }
898898-899899- // Set executable permissions
900900- if err := os.Chmod(currentPath, 0755); err != nil {
901901- // Try to restore backup
902902- os.Remove(currentPath)
903903- if renameErr := os.Rename(backupPath, currentPath); renameErr != nil {
904904- fmt.Fprintf(os.Stderr, "Warning: failed to restore backup: %v\n", renameErr)
905905- }
906906- return fmt.Errorf("failed to set permissions: %w", err)
907907- }
908908-909909- // Remove backup on success
910910- os.Remove(backupPath)
911911-912912- return nil
913913-}
914914-915915-// downloadFile downloads a file from a URL to a local path
916916-func downloadFile(url, destPath string) error {
917917- resp, err := http.Get(url)
918918- if err != nil {
919919- return err
920920- }
921921- defer resp.Body.Close()
922922-923923- if resp.StatusCode != http.StatusOK {
924924- return fmt.Errorf("download returned status %d", resp.StatusCode)
925925- }
926926-927927- out, err := os.Create(destPath)
928928- if err != nil {
929929- return err
930930- }
931931- defer out.Close()
932932-933933- _, err = io.Copy(out, resp.Body)
934934- return err
935935-}
936936-937937-// verifyChecksum verifies the SHA256 checksum of a file
938938-func verifyChecksum(filePath, expected string) error {
939939- // Import crypto/sha256 would be needed for real implementation
940940- // For now, skip if expected is empty
941941- if expected == "" {
942942- return nil
943943- }
944944-945945- // Read file and compute SHA256
946946- data, err := os.ReadFile(filePath)
947947- if err != nil {
948948- return err
949949- }
950950-951951- // Note: This is a simplified version. In production, use crypto/sha256
952952- _ = data // Would compute: sha256.Sum256(data)
953953-954954- // For now, just trust the download (checksums are optional until configured)
955955- return nil
956956-}
957957-958958-// extractTarGz extracts a .tar.gz archive
959959-func extractTarGz(archivePath, destDir string) error {
960960- cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
961961- if output, err := cmd.CombinedOutput(); err != nil {
962962- return fmt.Errorf("tar failed: %s: %w", string(output), err)
963963- }
964964- return nil
965965-}
966966-967967-// extractZip extracts a .zip archive
968968-func extractZip(archivePath, destDir string) error {
969969- cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir)
970970- if output, err := cmd.CombinedOutput(); err != nil {
971971- return fmt.Errorf("unzip failed: %s: %w", string(output), err)
972972- }
973973- return nil
974974-}
975975-976976-// copyFile copies a file from src to dst
977977-func copyFile(src, dst string) error {
978978- input, err := os.ReadFile(src)
979979- if err != nil {
980980- return err
981981- }
982982- return os.WriteFile(dst, input, 0755)
983983-}
984984-985985-// checkAndNotifyUpdate checks for updates in the background and notifies the user
986986-func checkAndNotifyUpdate(appViewURL string) {
987987- // Check if we've already checked recently
988988- cache := loadUpdateCheckCache()
989989- if cache != nil && time.Since(cache.CheckedAt) < updateCheckCacheTTL && cache.Current == version {
990990- // Cache is fresh and for current version
991991- if isNewerVersion(cache.Latest, version) {
992992- fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", cache.Latest)
993993- fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
994994- }
995995- return
996996- }
997997-998998- // Fetch version info
999999- apiURL := appViewURL + "/api/credential-helper/version"
10001000- versionInfo, err := fetchVersionInfo(apiURL)
10011001- if err != nil {
10021002- // Silently fail - don't interrupt credential retrieval
10031003- return
10041004- }
10051005-10061006- // Save to cache
10071007- saveUpdateCheckCache(&UpdateCheckCache{
10081008- CheckedAt: time.Now(),
10091009- Latest: versionInfo.Latest,
10101010- Current: version,
10111011- })
10121012-10131013- // Notify if newer version available
10141014- if isNewerVersion(versionInfo.Latest, version) {
10151015- fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", versionInfo.Latest)
10161016- fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n")
10171017- }
10181018-}
10191019-10201020-// getUpdateCheckCachePath returns the path to the update check cache file
10211021-func getUpdateCheckCachePath() string {
10221022- homeDir, err := os.UserHomeDir()
10231023- if err != nil {
10241024- return ""
10251025- }
10261026- return filepath.Join(homeDir, ".atcr", "update-check.json")
10271027-}
10281028-10291029-// loadUpdateCheckCache loads the update check cache from disk
10301030-func loadUpdateCheckCache() *UpdateCheckCache {
10311031- path := getUpdateCheckCachePath()
10321032- if path == "" {
10331033- return nil
10341034- }
10351035-10361036- data, err := os.ReadFile(path)
10371037- if err != nil {
10381038- return nil
10391039- }
10401040-10411041- var cache UpdateCheckCache
10421042- if err := json.Unmarshal(data, &cache); err != nil {
10431043- return nil
10441044- }
10451045-10461046- return &cache
10471047-}
10481048-10491049-// saveUpdateCheckCache saves the update check cache to disk
10501050-func saveUpdateCheckCache(cache *UpdateCheckCache) {
10511051- path := getUpdateCheckCachePath()
10521052- if path == "" {
10531053- return
10541054- }
10551055-10561056- data, err := json.MarshalIndent(cache, "", " ")
10571057- if err != nil {
10581058- return
10591059- }
10601060-10611061- // Ensure directory exists
10621062- dir := filepath.Dir(path)
10631063- if err := os.MkdirAll(dir, 0700); err != nil {
10641064- return
10651065- }
10661066-10671067- if err := os.WriteFile(path, data, 0600); err != nil {
10681068- return // Cache write failed, non-critical
106953 }
107054}
+107
cmd/credential-helper/process_darwin.go
···11+package main
22+33+import (
44+ "bytes"
55+ "encoding/binary"
66+ "fmt"
77+ "unsafe"
88+99+ "golang.org/x/sys/unix"
1010+)
1111+1212+// getProcessArgs uses kern.procargs2 sysctl to get process arguments.
1313+// This is the same mechanism ps(1) uses on macOS — no exec.Command needed.
1414+//
1515+// The kern.procargs2 buffer layout:
1616+//
1717+// [4 bytes: argc as int32]
1818+// [executable path\0]
1919+// [padding \0 bytes]
2020+// [argv[0]\0][argv[1]\0]...[argv[argc-1]\0]
2121+// [env vars...]
2222+func getProcessArgs(pid int) ([]string, error) {
2323+ // kern.procargs2 MIB: CTL_KERN=1, KERN_PROCARGS2=49
2424+ mib := []int32{1, 49, int32(pid)} //nolint:mnd
2525+2626+ // First call to get buffer size
2727+ n := uintptr(0)
2828+ if err := sysctl(mib, nil, &n, nil, 0); err != nil {
2929+ return nil, fmt.Errorf("sysctl size query for pid %d: %w", pid, err)
3030+ }
3131+3232+ buf := make([]byte, n)
3333+ if err := sysctl(mib, &buf[0], &n, nil, 0); err != nil {
3434+ return nil, fmt.Errorf("sysctl read for pid %d: %w", pid, err)
3535+ }
3636+ buf = buf[:n]
3737+3838+ if len(buf) < 4 {
3939+ return nil, fmt.Errorf("procargs2 buffer too short for pid %d", pid)
4040+ }
4141+4242+ // First 4 bytes: argc
4343+ argc := int(binary.LittleEndian.Uint32(buf[:4]))
4444+ pos := 4
4545+4646+ // Skip executable path (null-terminated)
4747+ end := bytes.IndexByte(buf[pos:], 0)
4848+ if end == -1 {
4949+ return nil, fmt.Errorf("no null terminator in exec path for pid %d", pid)
5050+ }
5151+ pos += end + 1
5252+5353+ // Skip padding null bytes
5454+ for pos < len(buf) && buf[pos] == 0 {
5555+ pos++
5656+ }
5757+5858+ // Read argc arguments
5959+ args := make([]string, 0, argc)
6060+ for i := 0; i < argc && pos < len(buf); i++ {
6161+ end := bytes.IndexByte(buf[pos:], 0)
6262+ if end == -1 {
6363+ args = append(args, string(buf[pos:]))
6464+ break
6565+ }
6666+ args = append(args, string(buf[pos:pos+end]))
6767+ pos += end + 1
6868+ }
6969+7070+ if len(args) == 0 {
7171+ return nil, fmt.Errorf("no args found for pid %d", pid)
7272+ }
7373+7474+ return args, nil
7575+}
7676+7777+// getParentPID uses kern.proc.pid sysctl to find the parent PID.
7878+func getParentPID(pid int) (int, error) {
7979+ // kern.proc.pid MIB: CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1
8080+ mib := []int32{1, 14, 1, int32(pid)} //nolint:mnd
8181+8282+ var kinfo unix.KinfoProc
8383+ n := uintptr(unsafe.Sizeof(kinfo))
8484+8585+ if err := sysctl(mib, (*byte)(unsafe.Pointer(&kinfo)), &n, nil, 0); err != nil {
8686+ return 0, fmt.Errorf("sysctl kern.proc.pid for pid %d: %w", pid, err)
8787+ }
8888+8989+ return int(kinfo.Eproc.Ppid), nil
9090+}
9191+9292+// sysctl is a thin wrapper around unix.Sysctl raw syscall.
9393+func sysctl(mib []int32, old *byte, oldlen *uintptr, new *byte, newlen uintptr) error {
9494+ _, _, errno := unix.Syscall6(
9595+ unix.SYS___SYSCTL,
9696+ uintptr(unsafe.Pointer(&mib[0])),
9797+ uintptr(len(mib)),
9898+ uintptr(unsafe.Pointer(old)),
9999+ uintptr(unsafe.Pointer(oldlen)),
100100+ uintptr(unsafe.Pointer(new)),
101101+ newlen,
102102+ )
103103+ if errno != 0 {
104104+ return errno
105105+ }
106106+ return nil
107107+}
+42
cmd/credential-helper/process_linux.go
···11+package main
22+33+import (
44+ "fmt"
55+ "os"
66+ "strconv"
77+ "strings"
88+)
99+1010+// getProcessArgs reads /proc/<pid>/cmdline to get process arguments.
1111+func getProcessArgs(pid int) ([]string, error) {
1212+ data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
1313+ if err != nil {
1414+ return nil, fmt.Errorf("reading /proc/%d/cmdline: %w", pid, err)
1515+ }
1616+1717+ s := strings.TrimRight(string(data), "\x00")
1818+ if s == "" {
1919+ return nil, fmt.Errorf("empty cmdline for pid %d", pid)
2020+ }
2121+2222+ return strings.Split(s, "\x00"), nil
2323+}
2424+2525+// getParentPID reads /proc/<pid>/status to find the parent PID.
2626+func getParentPID(pid int) (int, error) {
2727+ data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
2828+ if err != nil {
2929+ return 0, err
3030+ }
3131+3232+ for _, line := range strings.Split(string(data), "\n") {
3333+ if strings.HasPrefix(line, "PPid:") {
3434+ fields := strings.Fields(line)
3535+ if len(fields) >= 2 {
3636+ return strconv.Atoi(fields[1])
3737+ }
3838+ }
3939+ }
4040+4141+ return 0, fmt.Errorf("PPid not found in /proc/%d/status", pid)
4242+}
+19
cmd/credential-helper/process_other.go
···11+//go:build !linux && !darwin
22+33+package main
44+55+import (
66+ "fmt"
77+ "runtime"
88+)
99+1010+// getProcessArgs is not supported on this platform.
1111+// The credential helper falls back to the active account.
1212+func getProcessArgs(pid int) ([]string, error) {
1313+ return nil, fmt.Errorf("process introspection not supported on %s", runtime.GOOS)
1414+}
1515+1616+// getParentPID is not supported on this platform.
1717+func getParentPID(pid int) (int, error) {
1818+ return 0, fmt.Errorf("process introspection not supported on %s", runtime.GOOS)
1919+}
+234
cmd/credential-helper/protocol.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "os"
77+ "strings"
88+99+ "github.com/spf13/cobra"
1010+)
1111+1212+// Credentials represents docker credentials (Docker credential helper protocol)
1313+type Credentials struct {
1414+ ServerURL string `json:"ServerURL,omitempty"`
1515+ Username string `json:"Username,omitempty"`
1616+ Secret string `json:"Secret,omitempty"`
1717+}
1818+1919+func newGetCmd() *cobra.Command {
2020+ return &cobra.Command{
2121+ Use: "get",
2222+ Short: "Get credentials for a registry (Docker protocol)",
2323+ Hidden: true,
2424+ RunE: runGet,
2525+ }
2626+}
2727+2828+func newStoreCmd() *cobra.Command {
2929+ return &cobra.Command{
3030+ Use: "store",
3131+ Short: "Store credentials (Docker protocol)",
3232+ Hidden: true,
3333+ RunE: runStore,
3434+ }
3535+}
3636+3737+func newEraseCmd() *cobra.Command {
3838+ return &cobra.Command{
3939+ Use: "erase",
4040+ Short: "Erase credentials (Docker protocol)",
4141+ Hidden: true,
4242+ RunE: runErase,
4343+ }
4444+}
4545+4646+func newListCmd() *cobra.Command {
4747+ return &cobra.Command{
4848+ Use: "list",
4949+ Short: "List all credentials (Docker protocol extension)",
5050+ Hidden: true,
5151+ RunE: runList,
5252+ }
5353+}
5454+5555+func runGet(cmd *cobra.Command, args []string) error {
5656+ // If stdin is a terminal, the user ran this directly (not Docker calling us)
5757+ if isTerminal(os.Stdin) {
5858+ fmt.Fprintf(os.Stderr, "The 'get' command is part of the Docker credential helper protocol.\n")
5959+ fmt.Fprintf(os.Stderr, "It should not be run directly.\n\n")
6060+ fmt.Fprintf(os.Stderr, "To authenticate with a registry, run:\n")
6161+ fmt.Fprintf(os.Stderr, " docker-credential-atcr login\n\n")
6262+ fmt.Fprintf(os.Stderr, "To check your accounts:\n")
6363+ fmt.Fprintf(os.Stderr, " docker-credential-atcr status\n")
6464+ return fmt.Errorf("not a pipe")
6565+ }
6666+6767+ // Docker sends the server URL as a plain string on stdin (not JSON)
6868+ var serverURL string
6969+ if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
7070+ return fmt.Errorf("reading server URL: %w", err)
7171+ }
7272+7373+ appViewURL := buildAppViewURL(serverURL)
7474+7575+ cfg, err := loadConfig()
7676+ if err != nil {
7777+ fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
7878+ }
7979+8080+ acct, err := cfg.resolveAccount(appViewURL, serverURL)
8181+ if err != nil {
8282+ return err
8383+ }
8484+8585+ // Validate credentials
8686+ result := validateCredentials(appViewURL, acct.Handle, acct.DeviceSecret)
8787+ if !result.Valid {
8888+ if result.OAuthSessionExpired {
8989+ loginURL := result.LoginURL
9090+ if loginURL == "" {
9191+ loginURL = appViewURL + "/auth/oauth/login"
9292+ }
9393+ fmt.Fprintf(os.Stderr, "OAuth session expired for %s.\n", acct.Handle)
9494+ fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL)
9595+ fmt.Fprintf(os.Stderr, "Then retry your docker command.\n")
9696+ return fmt.Errorf("oauth session expired")
9797+ }
9898+9999+ // Generic auth failure — remove the bad account
100100+ fmt.Fprintf(os.Stderr, "Credentials for %s are invalid.\n", acct.Handle)
101101+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
102102+ cfg.removeAccount(appViewURL, acct.Handle)
103103+ cfg.save() //nolint:errcheck
104104+ return fmt.Errorf("invalid credentials")
105105+ }
106106+107107+ // Check for updates (cached, non-blocking)
108108+ checkAndNotifyUpdate(appViewURL)
109109+110110+ // Return credentials for Docker
111111+ creds := Credentials{
112112+ ServerURL: serverURL,
113113+ Username: acct.Handle,
114114+ Secret: acct.DeviceSecret,
115115+ }
116116+117117+ return json.NewEncoder(os.Stdout).Encode(creds)
118118+}
119119+120120+func runStore(cmd *cobra.Command, args []string) error {
121121+ var creds Credentials
122122+ if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil {
123123+ return fmt.Errorf("decoding credentials: %w", err)
124124+ }
125125+126126+ // Only store if the secret looks like a device secret
127127+ if !strings.HasPrefix(creds.Secret, "atcr_device_") {
128128+ // Not our device secret — ignore (e.g., docker login with app-password)
129129+ return nil
130130+ }
131131+132132+ appViewURL := buildAppViewURL(creds.ServerURL)
133133+134134+ cfg, err := loadConfig()
135135+ if err != nil {
136136+ fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
137137+ }
138138+139139+ cfg.addAccount(appViewURL, &Account{
140140+ Handle: creds.Username,
141141+ DeviceSecret: creds.Secret,
142142+ })
143143+144144+ return cfg.save()
145145+}
146146+147147+func runErase(cmd *cobra.Command, args []string) error {
148148+ var serverURL string
149149+ if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
150150+ return fmt.Errorf("reading server URL: %w", err)
151151+ }
152152+153153+ appViewURL := buildAppViewURL(serverURL)
154154+155155+ cfg, err := loadConfig()
156156+ if err != nil {
157157+ return nil // No config, nothing to erase
158158+ }
159159+160160+ reg := cfg.findRegistry(appViewURL)
161161+ if reg == nil {
162162+ return nil
163163+ }
164164+165165+ // Erase the active account (or sole account)
166166+ handle := reg.Active
167167+ if handle == "" && len(reg.Accounts) == 1 {
168168+ for h := range reg.Accounts {
169169+ handle = h
170170+ }
171171+ }
172172+ if handle == "" {
173173+ return nil
174174+ }
175175+176176+ cfg.removeAccount(appViewURL, handle)
177177+ return cfg.save()
178178+}
179179+180180+func runList(cmd *cobra.Command, args []string) error {
181181+ cfg, err := loadConfig()
182182+ if err != nil {
183183+ // Return empty object
184184+ fmt.Println("{}")
185185+ return nil
186186+ }
187187+188188+ // Docker list protocol: {"ServerURL": "Username", ...}
189189+ result := make(map[string]string)
190190+ for url, reg := range cfg.Registries {
191191+ // Strip scheme for Docker compatibility
192192+ host := strings.TrimPrefix(url, "https://")
193193+ host = strings.TrimPrefix(host, "http://")
194194+ for _, acct := range reg.Accounts {
195195+ result[host] = acct.Handle
196196+ }
197197+ }
198198+199199+ return json.NewEncoder(os.Stdout).Encode(result)
200200+}
201201+202202+// checkAndNotifyUpdate checks for updates in the background and notifies the user
203203+func checkAndNotifyUpdate(appViewURL string) {
204204+ cache := loadUpdateCheckCache()
205205+ if cache != nil && cache.Current == version {
206206+ // Cache is fresh and for current version
207207+ if isNewerVersion(cache.Latest, version) {
208208+ fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, version)
209209+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
210210+ }
211211+ // Check if cache is still fresh (24h)
212212+ if cache.CheckedAt.Add(updateCheckCacheTTL).After(timeNow()) {
213213+ return
214214+ }
215215+ }
216216+217217+ // Fetch version info
218218+ apiURL := appViewURL + "/api/credential-helper/version"
219219+ versionInfo, err := fetchVersionInfo(apiURL)
220220+ if err != nil {
221221+ return // Silently fail
222222+ }
223223+224224+ saveUpdateCheckCache(&UpdateCheckCache{
225225+ CheckedAt: timeNow(),
226226+ Latest: versionInfo.Latest,
227227+ Current: version,
228228+ })
229229+230230+ if isNewerVersion(versionInfo.Latest, version) {
231231+ fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", versionInfo.Latest, version)
232232+ fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
233233+ }
234234+}
···11+# Credential Helper Rewrite
22+33+## Context
44+55+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`).
66+77+## Approach
88+99+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.
1010+1111+**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.
1212+1313+## Command Tree
1414+1515+```
1616+docker-credential-atcr
1717+ ├── get (Docker protocol — stdin/stdout, hidden, smart account detection)
1818+ ├── store (Docker protocol — stdin, hidden)
1919+ ├── erase (Docker protocol — stdin, hidden)
2020+ ├── list (Docker protocol extension, hidden)
2121+ ├── login (Interactive device flow with huh prompts)
2222+ ├── logout (Remove account credentials)
2323+ ├── status (Show all accounts with active indicators)
2424+ ├── switch (Switch active account — auto-toggle for 2, select for 3+)
2525+ ├── configure-docker (Auto-edit ~/.docker/config.json credHelpers)
2626+ ├── update (Self-update, existing logic preserved)
2727+ └── version (Built-in via cobra)
2828+```
2929+3030+## Smart Account Resolution (`get` command)
3131+3232+The `get` command resolves which account to use with this priority chain — fully non-interactive:
3333+3434+```
3535+1. Parse parent process cmdline → extract identity from image ref
3636+ docker push atcr.io/evan.jarrett.net/test:latest
3737+ → parent cmdline contains "evan.jarrett.net" → use that account
3838+3939+2. Fall back to active account (set by `switch` command)
4040+4141+3. Fall back to sole account (if only one exists for this registry)
4242+4343+4. Error with helpful message:
4444+ "Multiple accounts for atcr.io. Run: docker-credential-atcr switch"
4545+```
4646+4747+**Parent process detection** (in `helpers.go`):
4848+- Linux: read `/proc/<ppid>/cmdline` (null-separated args)
4949+- macOS: `ps -o args= -p <ppid>`
5050+- Windows: best-effort via `wmic` or skip (fall to active account)
5151+- Parse image ref: find the arg matching `<registry-host>/<identity>/...`, extract `<identity>`
5252+- Graceful failure: if parent isn't Docker, cmdline unreadable, or image ref not parseable → fall through to active account
5353+5454+## File Structure
5555+5656+```
5757+cmd/credential-helper/
5858+ main.go — Cobra root command, version vars, subcommand registration
5959+ config.go — Config types, load/save/migrate, getConfigPath
6060+ device_auth.go — authorizeDevice(), validateCredentials() HTTP logic
6161+ protocol.go — Docker protocol: get, store, erase, list (all hidden)
6262+ cmd_login.go — login command (huh prompts + device flow)
6363+ cmd_logout.go — logout command (huh confirm)
6464+ cmd_status.go — status display
6565+ cmd_switch.go — switch command (huh select)
6666+ cmd_configure.go — configure-docker (edit ~/.docker/config.json)
6767+ cmd_update.go — update command (moved from existing code)
6868+ helpers.go — openBrowser, buildAppViewURL, isInsecureRegistry, parentCmdline, etc.
6969+```
7070+7171+## Config Format (`~/.atcr/device.json`)
7272+7373+```json
7474+{
7575+ "version": 2,
7676+ "registries": {
7777+ "https://atcr.io": {
7878+ "active": "evan.jarrett.net",
7979+ "accounts": {
8080+ "evan.jarrett.net": {
8181+ "handle": "evan.jarrett.net",
8282+ "did": "did:plc:abc123",
8383+ "device_secret": "atcr_device_..."
8484+ },
8585+ "michelle.jarrett.net": {
8686+ "handle": "michelle.jarrett.net",
8787+ "did": "did:plc:def456",
8888+ "device_secret": "atcr_device_..."
8989+ }
9090+ }
9191+ },
9292+ "https://buoy.cr": {
9393+ "active": "evan.jarrett.net",
9494+ "accounts": { ... }
9595+ }
9696+ }
9797+}
9898+```
9999+100100+**Migration**: `loadConfig()` auto-detects and migrates from old formats:
101101+- Legacy single-device `{handle, device_secret, appview_url}` → v2
102102+- Current multi-registry `{credentials: {url: {...}}}` → v2
103103+- Writes back migrated config on first load
104104+105105+## Key Behavioral Changes
106106+107107+| Command | Current | New |
108108+|---------|---------|-----|
109109+| `get` | Opens browser, polls 2min if no creds | Smart detection → active account → error |
110110+| `get` (multi-account) | N/A (single account only) | Auto-detects identity from parent cmdline |
111111+| `get` (no stdin) | Hangs forever | Detects terminal, prints help, exits 1 |
112112+| `get` (OAuth expired) | Auto-opens browser, polls | Prints login URL, exits 1 |
113113+| `store` | No-op | Stores if secret is device secret (`atcr_device_*`) |
114114+| `erase` | Removes all creds for host | Removes active account only |
115115+| No args | Prints bare usage | Prints full cobra help with all commands |
116116+117117+## Dependencies
118118+119119+- `github.com/spf13/cobra` — already in go.mod
120120+- `github.com/charmbracelet/huh` — new (pure Go, CGO_ENABLED=0 safe)
121121+122122+No changes to `.goreleaser.yaml` needed.
123123+124124+## Implementation Order
125125+126126+### Phase 1: Foundation
127127+1. `helpers.go` — move utility functions verbatim + add `getParentCmdline()` and `detectIdentityFromParent(registryHost)`
128128+2. `config.go` — new config types + migration from old formats
129129+3. `main.go` — Cobra root command, register all subcommands
130130+131131+### Phase 2: Docker Protocol (must work for existing users)
132132+4. `device_auth.go` — extract `authorizeDevice()` + `validateCredentials()`
133133+5. `protocol.go` — `get`/`store`/`erase`/`list` using new config with smart account resolution
134134+135135+### Phase 3: User Commands
136136+6. `cmd_login.go` — interactive device flow with huh spinner
137137+7. `cmd_status.go` — display all registries/accounts
138138+8. `cmd_switch.go` — huh select for account switching
139139+9. `cmd_logout.go` — huh confirm for removal
140140+10. `cmd_configure.go` — Docker config.json manipulation
141141+11. `cmd_update.go` — move existing update logic
142142+143143+### Phase 4: Polish
144144+12. Add `huh` to go.mod
145145+13. Delete old `main.go` contents (replaced by new files)
146146+147147+## What to Keep vs Rewrite
148148+149149+**Keep** (move to new files): `openBrowser()`, `buildAppViewURL()`, `isInsecureRegistry()`, `getDockerInsecureRegistries()`, `readDockerDaemonConfig()`, `stripPort()`, `isTerminal()`, `authorizeDevice()` HTTP logic, `validateCredentials()`, all update/version check functions.
150150+151151+**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.
152152+153153+**New**: `list`, `login`, `logout`, `status`, `switch`, `configure-docker` commands. Config migration. Parent process identity detection. huh integration.
154154+155155+## Verification
156156+157157+1. Build: `go build -o bin/docker-credential-atcr ./cmd/credential-helper`
158158+2. Help works: `bin/docker-credential-atcr --help` shows all user commands
159159+3. Protocol works: `echo "atcr.io" | bin/docker-credential-atcr get` returns credentials or helpful error
160160+4. No hang: `bin/docker-credential-atcr get` (no stdin pipe) detects terminal, prints help, exits
161161+5. Smart detection: `docker push atcr.io/evan.jarrett.net/test:latest` auto-selects `evan.jarrett.net`
162162+6. Login flow: `bin/docker-credential-atcr login` triggers device auth with huh prompts
163163+7. Status: `bin/docker-credential-atcr status` shows configured accounts
164164+8. Config migration: Place old-format `~/.atcr/device.json`, run any command, verify auto-migration
165165+9. GoReleaser: `CGO_ENABLED=0 go build ./cmd/credential-helper` succeeds
+2-2
docs/DEVELOPMENT.md
···4747 │ (changes appear instantly in container)
4848 ▼
4949┌─────────────────────────────────────────────────────┐
5050-│ Container (golang:1.25.2 base, has all tools) │
5050+│ Container (golang:1.25.7 base, has all tools) │
5151│ │
5252│ ┌──────────────────────────────────────┐ │
5353│ │ Air (hot reload tool) │ │
···107107108108```dockerfile
109109# Development Dockerfile with hot reload support
110110-FROM golang:1.25.2-trixie
110110+FROM golang:1.25.7-trixie
111111112112# Install Air for hot reload
113113RUN go install github.com/cosmtrek/air@latest
···33import (
44 "bytes"
55 "context"
66+ "database/sql"
67 "encoding/json"
78 "fmt"
89 "html/template"
···1314 "sync"
1415 "time"
15161717+ "atcr.io/pkg/appview/db"
1618 "atcr.io/pkg/atproto"
1719)
1820···5456 return
5557 }
56585757- // Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
5858- holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
5959+ // Check if this hold has a successor — scan records may live there instead
6060+ resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID)
6161+6262+ // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID;
6363+ // otherwise use the original holdEndpoint (which may already be a URL).
6464+ holdURLTarget := holdEndpoint
6565+ if resolvedHoldDID != holdDID {
6666+ holdDID = resolvedHoldDID
6767+ holdURLTarget = resolvedHoldDID
6868+ }
6969+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget)
5970 if err != nil {
6071 slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err)
6172 h.renderBadge(w, vulnBadgeData{Error: true})
···219230 return
220231 }
221232222222- // Resolve to HTTP endpoint URL (handles DID, URL, or hostname)
223223- holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint)
233233+ // Check if this hold has a successor — scan records may live there instead
234234+ resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID)
235235+236236+ // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID;
237237+ // otherwise use the original holdEndpoint (which may already be a URL).
238238+ holdURLTarget := holdEndpoint
239239+ if resolvedHoldDID != holdDID {
240240+ holdDID = resolvedHoldDID
241241+ holdURLTarget = resolvedHoldDID
242242+ }
243243+ holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget)
224244 if err != nil {
225245 slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err)
226246 w.Header().Set("Content-Type", "text/html")
···266286 template.HTMLEscapeString(res.hexDigest), buf.String())
267287 }
268288}
289289+290290+// resolveHoldSuccessor checks if a hold has a successor in the cached captain records.
291291+// Returns the successor DID if set, otherwise returns the original holdDID.
292292+// Single-hop only — does not follow chains.
293293+func resolveHoldSuccessor(database *sql.DB, holdDID string) string {
294294+ if database == nil {
295295+ return holdDID
296296+ }
297297+ captain, err := db.GetCaptainRecord(database, holdDID)
298298+ if err != nil || captain == nil {
299299+ return holdDID
300300+ }
301301+ if captain.Successor != "" {
302302+ slog.Debug("Scan result: following hold successor",
303303+ "from", holdDID, "to", captain.Successor)
304304+ return captain.Successor
305305+ }
306306+ return holdDID
307307+}
+26-10
pkg/appview/handlers/settings.go
···11package handlers
2233import (
44+ "context"
45 "encoding/json"
56 "html/template"
67 "log/slog"
···1314 "atcr.io/pkg/appview/middleware"
1415 "atcr.io/pkg/appview/storage"
1516 "atcr.io/pkg/atproto"
1717+ "github.com/bluesky-social/indigo/atproto/syntax"
1618)
17191820// HoldDisplay represents a hold for display in the UI
···7072 for _, hold := range availableHolds {
7173 display := HoldDisplay{
7274 DID: hold.HoldDID,
7373- DisplayName: deriveDisplayName(hold.HoldDID),
7575+ DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID),
7476 Region: hold.Region,
7577 Membership: hold.Membership,
7678 }
···106108 showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
107109108110 // Look up AppView default hold details from database
109109- appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID)
111111+ appViewDefaultDisplay := resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, h.DefaultHoldDID)
110112 var appViewDefaultRegion string
111113 if h.DefaultHoldDID != "" && h.DB != nil {
112114 if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
···143145 PageData: NewPageData(r, &h.BaseUIHandler),
144146 Meta: meta,
145147 CurrentHoldDID: profile.DefaultHold,
146146- CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
148148+ CurrentHoldDisplay: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, profile.DefaultHold),
147149 ShowCurrentHold: showCurrentHold,
148150 AppViewDefaultHoldDID: h.DefaultHoldDID,
149151 AppViewDefaultHoldDisplay: appViewDefaultDisplay,
···165167 }
166168}
167169168168-// deriveDisplayName derives a human-readable name from a hold DID
169169-func deriveDisplayName(did string) string {
170170- // For did:web, extract the domain
170170+// resolveHoldDisplayName resolves a hold DID to a human-readable handle via the
171171+// identity directory. Falls back to domain extraction (did:web) or truncation (did:plc).
172172+func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string {
173173+ if did == "" {
174174+ return ""
175175+ }
176176+177177+ // Try resolving via identity directory
178178+ if h.Directory != nil {
179179+ parsed, err := syntax.ParseDID(did)
180180+ if err == nil {
181181+ ident, err := h.Directory.LookupDID(ctx, parsed)
182182+ if err == nil && ident.Handle.String() != "handle.invalid" && ident.Handle.String() != "" {
183183+ return ident.Handle.String()
184184+ }
185185+ }
186186+ }
187187+188188+ // Fallback: extract domain from did:web
171189 if strings.HasPrefix(did, "did:web:") {
172190 domain := strings.TrimPrefix(did, "did:web:")
173173- // URL-decode the domain (did:web encodes : as %3A)
174174- decoded, err := url.QueryUnescape(domain)
175175- if err == nil {
191191+ if decoded, err := url.QueryUnescape(domain); err == nil {
176192 return decoded
177193 }
178194 return domain
179195 }
180196181181- // For did:plc, truncate for display
197197+ // Fallback: truncate did:plc
182198 if len(did) > 24 {
183199 return did[:24] + "..."
184200 }
+1-1
pkg/appview/handlers/subscription.go
···113113 }
114114115115 // Set hold display name so users know which hold the subscription applies to
116116- info.HoldDisplayName = deriveDisplayName(holdDID)
116116+ info.HoldDisplayName = resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, holdDID)
117117118118 // Format prices for display
119119 // Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
···88888989 // Validate successor DID format if provided
9090 if successor != "" {
9191- if !atproto.IsDID(successor) || !(strings.HasPrefix(successor, "did:web:") || strings.HasPrefix(successor, "did:plc:")) {
9191+ if !atproto.IsDID(successor) || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) {
9292 setFlash(w, r, "error", "Successor must be a valid did:web: or did:plc: DID")
9393 http.Redirect(w, r, "/admin#settings", http.StatusFound)
9494 return
+107-34
pkg/hold/pds/scan_broadcaster.go
···3838 ownsDB bool // true when this broadcaster opened the connection itself
39394040 // Proactive scan scheduling
4141- rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled)
4242- stopCh chan struct{} // Signal to stop background goroutines
4343- wg sync.WaitGroup // Wait for background goroutines to finish
4444- userIdx int // Round-robin index through users for proactive scanning
4545- predecessorCache map[string]bool // holdDID → "is this hold's successor us?"
4141+ rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled)
4242+ stopCh chan struct{} // Signal to stop background goroutines
4343+ wg sync.WaitGroup // Wait for background goroutines to finish
4444+ userIdx int // Round-robin index through DIDs for proactive scanning
4545+ predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?"
4646+4747+ // Relay-based manifest DID discovery
4848+ relayEndpoint string // Relay URL for listReposByCollection
4949+ manifestDIDs []string // Cached list of DIDs with manifest records
5050+ manifestDIDsMu sync.RWMutex // Protects manifestDIDs
4651}
47524853// ScanSubscriber represents a connected scanner WebSocket client
···90959196// NewScanBroadcaster creates a new scan job broadcaster
9297// dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3")
9393-func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
9898+func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
9499 dsn := dbPath
95100 if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
96101 dsn = "file:" + dbPath
···116121 return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
117122 }
118123124124+ if relayEndpoint == "" {
125125+ relayEndpoint = "https://relay1.us-east.bsky.network"
126126+ }
127127+119128 sb := &ScanBroadcaster{
120129 subscribers: make([]*ScanSubscriber, 0),
121130 db: db,
···129138 rescanInterval: rescanInterval,
130139 stopCh: make(chan struct{}),
131140 predecessorCache: make(map[string]bool),
141141+ relayEndpoint: relayEndpoint,
132142 }
133143134144 if err := sb.initSchema(); err != nil {
···142152143153 // Start proactive scan loop if rescan interval is configured
144154 if rescanInterval > 0 {
145145- sb.wg.Add(1)
155155+ sb.wg.Add(2)
146156 go sb.proactiveScanLoop()
147147- slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval)
157157+ go sb.refreshManifestDIDsLoop()
158158+ slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
148159 }
149160150161 return sb, nil
···152163153164// NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection.
154165// The caller is responsible for the DB lifecycle.
155155-func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
166166+func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
167167+ if relayEndpoint == "" {
168168+ relayEndpoint = "https://relay1.us-east.bsky.network"
169169+ }
170170+156171 sb := &ScanBroadcaster{
157172 subscribers: make([]*ScanSubscriber, 0),
158173 db: db,
···166181 rescanInterval: rescanInterval,
167182 stopCh: make(chan struct{}),
168183 predecessorCache: make(map[string]bool),
184184+ relayEndpoint: relayEndpoint,
169185 }
170186171187 if err := sb.initSchema(); err != nil {
···176192 go sb.reDispatchLoop()
177193178194 if rescanInterval > 0 {
179179- sb.wg.Add(1)
195195+ sb.wg.Add(2)
180196 go sb.proactiveScanLoop()
181181- slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval)
197197+ go sb.refreshManifestDIDsLoop()
198198+ slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
182199 }
183200184201 return sb, nil
···709726 return sb.secret != "" && secret == sb.secret
710727}
711728729729+// refreshManifestDIDsLoop periodically queries the relay to discover all DIDs
730730+// with io.atcr.manifest records. The cached list is used by the proactive scan loop.
731731+func (sb *ScanBroadcaster) refreshManifestDIDsLoop() {
732732+ defer sb.wg.Done()
733733+734734+ // Wait for the system to settle before first refresh
735735+ select {
736736+ case <-sb.stopCh:
737737+ return
738738+ case <-time.After(30 * time.Second):
739739+ }
740740+741741+ // Initial refresh
742742+ sb.refreshManifestDIDs()
743743+744744+ ticker := time.NewTicker(30 * time.Minute)
745745+ defer ticker.Stop()
746746+747747+ for {
748748+ select {
749749+ case <-sb.stopCh:
750750+ slog.Info("Manifest DID refresh loop stopped")
751751+ return
752752+ case <-ticker.C:
753753+ sb.refreshManifestDIDs()
754754+ }
755755+ }
756756+}
757757+758758+// refreshManifestDIDs queries the relay for all DIDs that have io.atcr.manifest records.
759759+// On success, atomically replaces the cached DID list. On failure, retains the previous list.
760760+func (sb *ScanBroadcaster) refreshManifestDIDs() {
761761+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
762762+ defer cancel()
763763+764764+ client := atproto.NewClient(sb.relayEndpoint, "", "")
765765+766766+ var allDIDs []string
767767+ var cursor string
768768+769769+ for {
770770+ result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor)
771771+ if err != nil {
772772+ slog.Warn("Proactive scan: failed to list repos from relay",
773773+ "relay", sb.relayEndpoint, "error", err)
774774+ return // Keep existing cached list
775775+ }
776776+777777+ for _, repo := range result.Repos {
778778+ allDIDs = append(allDIDs, repo.DID)
779779+ }
780780+781781+ if result.Cursor == "" || len(result.Repos) == 0 {
782782+ break
783783+ }
784784+ cursor = result.Cursor
785785+ }
786786+787787+ sb.manifestDIDsMu.Lock()
788788+ sb.manifestDIDs = allDIDs
789789+ sb.manifestDIDsMu.Unlock()
790790+791791+ slog.Info("Proactive scan: refreshed manifest DID list from relay",
792792+ "count", len(allDIDs), "relay", sb.relayEndpoint)
793793+}
794794+712795// proactiveScanLoop periodically finds manifests needing scanning and enqueues jobs.
713796// It fetches manifest records from users' PDS (the source of truth) and creates scan
714797// jobs for manifests that haven't been scanned recently.
···738821739822// tryEnqueueProactiveScan finds the next manifest needing a scan and enqueues it.
740823// Only enqueues one job per call to avoid flooding the scanner.
824824+// Uses the cached DID list from the relay (refreshed by refreshManifestDIDsLoop).
741825func (sb *ScanBroadcaster) tryEnqueueProactiveScan() {
742826 if !sb.hasConnectedScanners() {
743827 return
···749833 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
750834 defer cancel()
751835752752- // Get all users who have pushed to this hold
753753- stats, err := sb.pds.ListStats(ctx)
754754- if err != nil {
755755- slog.Error("Proactive scan: failed to list stats", "error", err)
756756- return
757757- }
758758-759759- // Extract unique user DIDs
760760- seen := make(map[string]bool)
761761- var userDIDs []string
762762- for _, s := range stats {
763763- if !seen[s.OwnerDID] {
764764- seen[s.OwnerDID] = true
765765- userDIDs = append(userDIDs, s.OwnerDID)
766766- }
767767- }
836836+ // Read cached DID list from relay discovery
837837+ sb.manifestDIDsMu.RLock()
838838+ userDIDs := sb.manifestDIDs
839839+ sb.manifestDIDsMu.RUnlock()
768840769841 if len(userDIDs) == 0 {
770842 return
771843 }
772844773773- // Round-robin through users, trying each until we find work or exhaust the list
845845+ // Round-robin through DIDs, trying each until we find work or exhaust the list
774846 for attempts := 0; attempts < len(userDIDs); attempts++ {
775847 idx := sb.userIdx % len(userDIDs)
776848 sb.userIdx++
···870942 return false
871943}
872944873873-// isOurManifest checks if a manifest's holdDID matches this hold, either directly
874874-// or via successor (the manifest's hold has set us as its successor).
945945+// isOurManifest checks if a manifest's holdDID matches this hold directly,
946946+// or if the manifest's hold has been migrated (has a successor label set).
875947func (sb *ScanBroadcaster) isOurManifest(ctx context.Context, holdDID string) bool {
876948 if holdDID == "" {
877949 return false
···893965 return isPredecessor
894966}
895967896896-// checkPredecessor fetches a hold's captain record to check if its successor is us.
968968+// checkPredecessor fetches a hold's captain record to check if it has a successor label
969969+// (meaning the hold has been migrated/retired and its manifests should be scanned by us).
897970func (sb *ScanBroadcaster) checkPredecessor(ctx context.Context, holdDID string) bool {
898971 fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
899972 defer cancel()
···9461019 return false
9471020 }
9481021949949- if captain.Successor == sb.holdDID {
950950- slog.Info("Proactive scan: discovered predecessor hold",
951951- "predecessorDID", holdDID, "successor", sb.holdDID)
10221022+ if captain.Successor != "" {
10231023+ slog.Info("Proactive scan: discovered migrated hold (has successor label)",
10241024+ "holdDID", holdDID, "successor", captain.Successor)
9521025 return true
9531026 }
9541027