A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

vuln scanner fixes, major refactor of the credential helper.

evan.jarrett.net 27cf7815 dba20199

verified
+2769 -1211
+4 -2
Dockerfile.scanner
··· 8 8 9 9 WORKDIR /build 10 10 11 - # Copy go.work and both module definitions first for layer caching 12 - COPY go.work ./ 11 + # Disable workspace mode — go.work references modules not in the Docker context 12 + ENV GOWORK=off 13 + 14 + # Copy module definitions first for layer caching 13 15 COPY go.mod go.sum ./ 14 16 COPY scanner/go.mod scanner/go.sum ./scanner/ 15 17
+29 -24
Formula/docker-credential-atcr.rb
··· 4 4 class DockerCredentialAtcr < Formula 5 5 desc "Docker credential helper for ATCR (ATProto Container Registry)" 6 6 homepage "https://atcr.io" 7 - url "https://github.com/atcr-io/atcr/archive/refs/tags/v0.0.1.tar.gz" 8 - sha256 "REPLACE_WITH_TARBALL_SHA256" 7 + version "0.0.1" 9 8 license "MIT" 10 - head "https://github.com/atcr-io/atcr.git", branch: "main" 9 + 10 + on_macos do 11 + on_arm do 12 + 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" 13 + sha256 "REPLACE_WITH_SHA256" 14 + end 15 + on_intel do 16 + 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" 17 + sha256 "REPLACE_WITH_SHA256" 18 + end 19 + end 11 20 12 - depends_on "go" => :build 21 + on_linux do 22 + on_arm do 23 + 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" 24 + sha256 "REPLACE_WITH_SHA256" 25 + end 26 + on_intel do 27 + 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" 28 + sha256 "REPLACE_WITH_SHA256" 29 + end 30 + end 13 31 14 32 def install 15 - # Build the credential helper binary 16 - # Use ldflags to inject version information 17 - ldflags = %W[ 18 - -s -w 19 - -X main.version=#{version} 20 - -X main.commit=#{tap.user} 21 - -X main.date=#{time.iso8601} 22 - ] 23 - 24 - system "go", "build", *std_go_args(ldflags:, output: bin/"docker-credential-atcr"), "./cmd/credential-helper" 33 + bin.install "docker-credential-atcr" 25 34 end 26 35 27 36 test do 28 - # Test that the binary exists and is executable 29 37 assert_match version.to_s, shell_output("#{bin}/docker-credential-atcr version 2>&1") 30 38 end 31 39 ··· 34 42 To configure Docker to use ATCR credential helper, add the following 35 43 to your ~/.docker/config.json: 36 44 37 - { 38 - "credHelpers": { 39 - "atcr.io": "atcr" 45 + { 46 + "credHelpers": { 47 + "atcr.io": "atcr" 48 + } 40 49 } 41 - } 42 50 43 - Note: The credential helper name is "atcr" (Docker automatically prefixes 44 - with "docker-credential-" when looking for the binary). 51 + Or run: docker-credential-atcr configure-docker 45 52 46 53 To authenticate with ATCR: 47 54 docker push atcr.io/<your-handle>/<image>:latest 48 55 49 - This will open your browser to complete the OAuth device flow. 50 - 51 - Configuration is stored in: ~/.atcr/device.json 56 + Configuration is stored in: ~/.atcr/config.json 52 57 EOS 53 58 end 54 59 end
+18 -3
Makefile
··· 3 3 4 4 .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ 5 5 generate test test-race test-verbose lint clean help install-credential-helper \ 6 - develop develop-detached develop-down dev 6 + develop develop-detached develop-down dev \ 7 + docker docker-appview docker-hold docker-scanner 7 8 8 9 .DEFAULT_GOAL := help 9 10 ··· 40 41 @mkdir -p bin 41 42 go build -o bin/atcr-hold ./cmd/hold 42 43 43 - build-credential-helper: $(GENERATED_ASSETS) ## Build credential helper only 44 + build-credential-helper: ## Build credential helper only 44 45 @echo "→ Building credential helper..." 45 46 @mkdir -p bin 46 47 go build -o bin/docker-credential-atcr ./cmd/credential-helper 47 48 48 - build-oauth-helper: $(GENERATED_ASSETS) ## Build OAuth helper only 49 + build-oauth-helper: ## Build OAuth helper only 49 50 @echo "→ Building OAuth helper..." 50 51 @mkdir -p bin 51 52 go build -o bin/oauth-helper ./cmd/oauth-helper ··· 88 89 air -c .air.toml 89 90 90 91 ##@ Docker Targets 92 + 93 + docker: docker-appview docker-hold docker-scanner ## Build all Docker images 94 + 95 + docker-appview: ## Build appview Docker image 96 + @echo "→ Building appview Docker image..." 97 + docker build -f Dockerfile.appview -t atcr.io/atcr.io/appview:latest . 98 + 99 + docker-hold: ## Build hold Docker image 100 + @echo "→ Building hold Docker image..." 101 + docker build -f Dockerfile.hold -t atcr.io/atcr.io/hold:latest . 102 + 103 + docker-scanner: ## Build scanner Docker image 104 + @echo "→ Building scanner Docker image..." 105 + docker build -f Dockerfile.scanner -t atcr.io/atcr.io/scanner:latest . 91 106 92 107 develop: ## Build and start docker-compose with Air hot reload 93 108 @echo "→ Building Docker images..."
+159
cmd/credential-helper/cmd_configure.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + 10 + "github.com/charmbracelet/huh" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + func newConfigureDockerCmd() *cobra.Command { 15 + return &cobra.Command{ 16 + Use: "configure-docker", 17 + Short: "Configure Docker to use this credential helper", 18 + Long: "Adds or updates the credHelpers entry in ~/.docker/config.json\nfor all configured registries.", 19 + RunE: runConfigureDocker, 20 + } 21 + } 22 + 23 + func runConfigureDocker(cmd *cobra.Command, args []string) error { 24 + cfg, err := loadConfig() 25 + if err != nil { 26 + return fmt.Errorf("loading config: %w", err) 27 + } 28 + 29 + if len(cfg.Registries) == 0 { 30 + fmt.Fprintf(os.Stderr, "No registries configured.\n") 31 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") 32 + return nil 33 + } 34 + 35 + // Collect registry hosts 36 + var hosts []string 37 + for url := range cfg.Registries { 38 + host := strings.TrimPrefix(url, "https://") 39 + host = strings.TrimPrefix(host, "http://") 40 + hosts = append(hosts, host) 41 + } 42 + 43 + dockerConfigPath := getDockerConfigPath() 44 + 45 + // Load existing Docker config 46 + dockerCfg := loadDockerConfig() 47 + if dockerCfg == nil { 48 + dockerCfg = make(map[string]any) 49 + } 50 + 51 + // Get or create credHelpers 52 + helpers, ok := dockerCfg["credHelpers"] 53 + if !ok { 54 + helpers = make(map[string]any) 55 + } 56 + helpersMap, ok := helpers.(map[string]any) 57 + if !ok { 58 + helpersMap = make(map[string]any) 59 + } 60 + 61 + // Check what needs to change 62 + var toAdd []string 63 + for _, host := range hosts { 64 + current, exists := helpersMap[host] 65 + if !exists || current != "atcr" { 66 + toAdd = append(toAdd, host) 67 + } 68 + } 69 + 70 + if len(toAdd) == 0 { 71 + fmt.Printf("Docker is already configured for all registries.\n") 72 + return nil 73 + } 74 + 75 + fmt.Printf("Will update %s:\n", dockerConfigPath) 76 + for _, host := range toAdd { 77 + fmt.Printf(" + credHelpers[%q] = \"atcr\"\n", host) 78 + } 79 + fmt.Println() 80 + 81 + var confirm bool 82 + err = huh.NewConfirm(). 83 + Title("Apply changes?"). 84 + Value(&confirm). 85 + Run() 86 + if err != nil || !confirm { 87 + fmt.Fprintf(os.Stderr, "Cancelled.\n") 88 + return nil 89 + } 90 + 91 + // Apply changes 92 + for _, host := range toAdd { 93 + helpersMap[host] = "atcr" 94 + } 95 + dockerCfg["credHelpers"] = helpersMap 96 + 97 + // Remove conflicting credsStore if it exists and we're adding credHelpers 98 + if _, hasStore := dockerCfg["credsStore"]; hasStore { 99 + fmt.Fprintf(os.Stderr, "Note: credsStore is set — credHelpers takes precedence for configured registries.\n") 100 + } 101 + 102 + if err := saveDockerConfig(dockerConfigPath, dockerCfg); err != nil { 103 + return fmt.Errorf("saving Docker config: %w", err) 104 + } 105 + 106 + fmt.Printf("Docker configured successfully.\n") 107 + return nil 108 + } 109 + 110 + // getDockerConfigPath returns the path to Docker's config.json 111 + func getDockerConfigPath() string { 112 + // Check DOCKER_CONFIG env var first 113 + if dir := os.Getenv("DOCKER_CONFIG"); dir != "" { 114 + return filepath.Join(dir, "config.json") 115 + } 116 + 117 + homeDir, err := os.UserHomeDir() 118 + if err != nil { 119 + return "" 120 + } 121 + return filepath.Join(homeDir, ".docker", "config.json") 122 + } 123 + 124 + // loadDockerConfig loads Docker's config.json as a generic map 125 + func loadDockerConfig() map[string]any { 126 + path := getDockerConfigPath() 127 + if path == "" { 128 + return nil 129 + } 130 + 131 + data, err := os.ReadFile(path) 132 + if err != nil { 133 + return nil 134 + } 135 + 136 + var config map[string]any 137 + if err := json.Unmarshal(data, &config); err != nil { 138 + return nil 139 + } 140 + 141 + return config 142 + } 143 + 144 + // saveDockerConfig writes Docker's config.json 145 + func saveDockerConfig(path string, config map[string]any) error { 146 + // Ensure directory exists 147 + dir := filepath.Dir(path) 148 + if err := os.MkdirAll(dir, 0700); err != nil { 149 + return err 150 + } 151 + 152 + data, err := json.MarshalIndent(config, "", "\t") 153 + if err != nil { 154 + return err 155 + } 156 + data = append(data, '\n') 157 + 158 + return os.WriteFile(path, data, 0600) 159 + }
+181
cmd/credential-helper/cmd_login.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "os" 7 + "strings" 8 + 9 + "github.com/charmbracelet/huh" 10 + "github.com/charmbracelet/huh/spinner" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + func newLoginCmd() *cobra.Command { 15 + cmd := &cobra.Command{ 16 + Use: "login [registry]", 17 + Short: "Authenticate with a container registry", 18 + Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: atcr.io", 19 + Args: cobra.MaximumNArgs(1), 20 + RunE: runLogin, 21 + } 22 + return cmd 23 + } 24 + 25 + func runLogin(cmd *cobra.Command, args []string) error { 26 + serverURL := "atcr.io" 27 + if len(args) > 0 { 28 + serverURL = args[0] 29 + } 30 + 31 + appViewURL := buildAppViewURL(serverURL) 32 + 33 + cfg, err := loadConfig() 34 + if err != nil { 35 + fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) 36 + } 37 + 38 + // Check if already logged in 39 + reg := cfg.findRegistry(appViewURL) 40 + if reg != nil && len(reg.Accounts) > 0 { 41 + var lines []string 42 + for _, acct := range reg.Accounts { 43 + lines = append(lines, acct.Handle) 44 + } 45 + 46 + var addAnother bool 47 + err := huh.NewConfirm(). 48 + Title("Already logged in to " + appViewURL). 49 + Description("Accounts: " + strings.Join(lines, ", ")). 50 + Value(&addAnother). 51 + Affirmative("Add another account"). 52 + Negative("Cancel"). 53 + Run() 54 + if err != nil || !addAnother { 55 + return nil 56 + } 57 + } 58 + 59 + // 1. Request device code 60 + codeResp, resolvedURL, err := requestDeviceCode(serverURL) 61 + if err != nil { 62 + return fmt.Errorf("device authorization failed: %w", err) 63 + } 64 + 65 + verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode 66 + 67 + // 2. Show code and open browser 68 + fmt.Fprintln(os.Stderr) 69 + logWarning("First copy your one-time code: %s", bold(codeResp.UserCode)) 70 + 71 + if isTerminal(os.Stdin) { 72 + // Interactive: wait for Enter before opening browser 73 + logInfof("Press Enter to open %s in your browser... ", codeResp.VerificationURI) 74 + reader := bufio.NewReader(os.Stdin) 75 + reader.ReadString('\n') //nolint:errcheck 76 + 77 + if err := openBrowser(verificationURL); err != nil { 78 + logWarning("Could not open browser automatically.") 79 + fmt.Fprintf(os.Stderr, " Visit: %s\n", verificationURL) 80 + } 81 + } else { 82 + // Non-interactive: just print the URL 83 + logInfo("Visit this URL in your browser:") 84 + fmt.Fprintf(os.Stderr, " %s\n", verificationURL) 85 + } 86 + 87 + // 3. Poll for authorization with spinner 88 + var acct *Account 89 + var pollErr error 90 + if err := spinner.New(). 91 + Title("Waiting for authentication..."). 92 + Action(func() { 93 + acct, pollErr = pollDeviceToken(resolvedURL, codeResp) 94 + }). 95 + Run(); err != nil { 96 + return err 97 + } 98 + if pollErr != nil { 99 + return fmt.Errorf("device authorization failed: %w", pollErr) 100 + } 101 + 102 + logSuccess("Authentication complete.") 103 + 104 + // 4. Save 105 + cfg.addAccount(resolvedURL, acct) 106 + if err := cfg.save(); err != nil { 107 + return fmt.Errorf("saving config: %w", err) 108 + } 109 + 110 + logSuccess("Logged in as %s on %s", bold(acct.Handle), resolvedURL) 111 + 112 + // 5. Offer to configure Docker if not already set up 113 + if isTerminal(os.Stdin) && !isDockerConfigured(serverURL) { 114 + fmt.Fprintf(os.Stderr, "\n") 115 + var configureDkr bool 116 + err := huh.NewConfirm(). 117 + Title("Configure Docker to use this credential helper?"). 118 + Description("Adds credHelpers entry to ~/.docker/config.json"). 119 + Value(&configureDkr). 120 + Run() 121 + if err == nil && configureDkr { 122 + if configureErr := configureDockerForRegistry(serverURL); configureErr != nil { 123 + logWarning("Failed to configure Docker: %v", configureErr) 124 + } else { 125 + logSuccess("Configured Docker for %s", serverURL) 126 + } 127 + } 128 + } 129 + 130 + return nil 131 + } 132 + 133 + // isDockerConfigured checks if Docker's config.json has this registry in credHelpers 134 + func isDockerConfigured(serverURL string) bool { 135 + dockerConfig := loadDockerConfig() 136 + if dockerConfig == nil { 137 + return false 138 + } 139 + 140 + helpers, ok := dockerConfig["credHelpers"] 141 + if !ok { 142 + return false 143 + } 144 + 145 + helpersMap, ok := helpers.(map[string]any) 146 + if !ok { 147 + return false 148 + } 149 + 150 + host := strings.TrimPrefix(serverURL, "https://") 151 + host = strings.TrimPrefix(host, "http://") 152 + 153 + _, ok = helpersMap[host] 154 + return ok 155 + } 156 + 157 + // configureDockerForRegistry adds a credHelpers entry for a single registry 158 + func configureDockerForRegistry(serverURL string) error { 159 + host := strings.TrimPrefix(serverURL, "https://") 160 + host = strings.TrimPrefix(host, "http://") 161 + 162 + dockerConfigPath := getDockerConfigPath() 163 + dockerCfg := loadDockerConfig() 164 + if dockerCfg == nil { 165 + dockerCfg = make(map[string]any) 166 + } 167 + 168 + helpers, ok := dockerCfg["credHelpers"] 169 + if !ok { 170 + helpers = make(map[string]any) 171 + } 172 + helpersMap, ok := helpers.(map[string]any) 173 + if !ok { 174 + helpersMap = make(map[string]any) 175 + } 176 + 177 + helpersMap[host] = "atcr" 178 + dockerCfg["credHelpers"] = helpersMap 179 + 180 + return saveDockerConfig(dockerConfigPath, dockerCfg) 181 + }
+93
cmd/credential-helper/cmd_logout.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "sort" 7 + 8 + "github.com/charmbracelet/huh" 9 + "github.com/spf13/cobra" 10 + ) 11 + 12 + func newLogoutCmd() *cobra.Command { 13 + return &cobra.Command{ 14 + Use: "logout [registry]", 15 + Short: "Remove account credentials", 16 + Long: "Remove stored credentials for an account.\nDefault registry: atcr.io", 17 + Args: cobra.MaximumNArgs(1), 18 + RunE: runLogout, 19 + } 20 + } 21 + 22 + func runLogout(cmd *cobra.Command, args []string) error { 23 + serverURL := "atcr.io" 24 + if len(args) > 0 { 25 + serverURL = args[0] 26 + } 27 + 28 + appViewURL := buildAppViewURL(serverURL) 29 + 30 + cfg, err := loadConfig() 31 + if err != nil { 32 + return fmt.Errorf("loading config: %w", err) 33 + } 34 + 35 + reg := cfg.findRegistry(appViewURL) 36 + if reg == nil || len(reg.Accounts) == 0 { 37 + fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL) 38 + return nil 39 + } 40 + 41 + // Determine which account to remove 42 + var handle string 43 + 44 + if len(reg.Accounts) == 1 { 45 + for h := range reg.Accounts { 46 + handle = h 47 + } 48 + } else { 49 + // Multiple accounts — select which to remove 50 + var handles []string 51 + for h := range reg.Accounts { 52 + handles = append(handles, h) 53 + } 54 + sort.Strings(handles) 55 + 56 + var options []huh.Option[string] 57 + for _, h := range handles { 58 + label := h 59 + if h == reg.Active { 60 + label += " (active)" 61 + } 62 + options = append(options, huh.NewOption(label, h)) 63 + } 64 + 65 + err := huh.NewSelect[string](). 66 + Title("Which account to remove?"). 67 + Options(options...). 68 + Value(&handle). 69 + Run() 70 + if err != nil { 71 + return err 72 + } 73 + } 74 + 75 + // Confirm 76 + var confirm bool 77 + err = huh.NewConfirm(). 78 + Title(fmt.Sprintf("Remove %s from %s?", handle, serverURL)). 79 + Value(&confirm). 80 + Run() 81 + if err != nil || !confirm { 82 + fmt.Fprintf(os.Stderr, "Cancelled.\n") 83 + return nil 84 + } 85 + 86 + cfg.removeAccount(appViewURL, handle) 87 + if err := cfg.save(); err != nil { 88 + return fmt.Errorf("saving config: %w", err) 89 + } 90 + 91 + fmt.Printf("Removed %s from %s\n", handle, serverURL) 92 + return nil 93 + }
+65
cmd/credential-helper/cmd_status.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "sort" 7 + 8 + "github.com/spf13/cobra" 9 + ) 10 + 11 + func newStatusCmd() *cobra.Command { 12 + return &cobra.Command{ 13 + Use: "status", 14 + Short: "Show all configured accounts", 15 + RunE: runStatus, 16 + } 17 + } 18 + 19 + func runStatus(cmd *cobra.Command, args []string) error { 20 + cfg, err := loadConfig() 21 + if err != nil { 22 + return fmt.Errorf("loading config: %w", err) 23 + } 24 + 25 + if len(cfg.Registries) == 0 { 26 + fmt.Fprintf(os.Stderr, "No accounts configured.\n") 27 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") 28 + return nil 29 + } 30 + 31 + // Sort registry URLs for stable output 32 + var urls []string 33 + for url := range cfg.Registries { 34 + urls = append(urls, url) 35 + } 36 + sort.Strings(urls) 37 + 38 + for _, url := range urls { 39 + reg := cfg.Registries[url] 40 + fmt.Printf("%s\n", url) 41 + 42 + // Sort handles for stable output 43 + var handles []string 44 + for h := range reg.Accounts { 45 + handles = append(handles, h) 46 + } 47 + sort.Strings(handles) 48 + 49 + for _, handle := range handles { 50 + acct := reg.Accounts[handle] 51 + marker := " " 52 + if handle == reg.Active { 53 + marker = "* " 54 + } 55 + did := "" 56 + if acct.DID != "" { 57 + did = fmt.Sprintf(" (%s)", acct.DID) 58 + } 59 + fmt.Printf(" %s%s%s\n", marker, handle, did) 60 + } 61 + fmt.Println() 62 + } 63 + 64 + return nil 65 + }
+96
cmd/credential-helper/cmd_switch.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "sort" 7 + 8 + "github.com/charmbracelet/huh" 9 + "github.com/spf13/cobra" 10 + ) 11 + 12 + func newSwitchCmd() *cobra.Command { 13 + return &cobra.Command{ 14 + Use: "switch [registry]", 15 + Short: "Switch the active account for a registry", 16 + Long: "Switch the active account used for Docker operations.\nDefault registry: atcr.io", 17 + Args: cobra.MaximumNArgs(1), 18 + RunE: runSwitch, 19 + } 20 + } 21 + 22 + func runSwitch(cmd *cobra.Command, args []string) error { 23 + serverURL := "atcr.io" 24 + if len(args) > 0 { 25 + serverURL = args[0] 26 + } 27 + 28 + appViewURL := buildAppViewURL(serverURL) 29 + 30 + cfg, err := loadConfig() 31 + if err != nil { 32 + return fmt.Errorf("loading config: %w", err) 33 + } 34 + 35 + reg := cfg.findRegistry(appViewURL) 36 + if reg == nil || len(reg.Accounts) == 0 { 37 + fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL) 38 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") 39 + return nil 40 + } 41 + 42 + if len(reg.Accounts) == 1 { 43 + for h := range reg.Accounts { 44 + fmt.Fprintf(os.Stderr, "Only one account (%s) — nothing to switch.\n", h) 45 + } 46 + return nil 47 + } 48 + 49 + // For exactly 2 accounts, just toggle 50 + if len(reg.Accounts) == 2 { 51 + for h := range reg.Accounts { 52 + if h != reg.Active { 53 + reg.Active = h 54 + if err := cfg.save(); err != nil { 55 + return fmt.Errorf("saving config: %w", err) 56 + } 57 + fmt.Printf("Switched to %s on %s\n", h, serverURL) 58 + return nil 59 + } 60 + } 61 + } 62 + 63 + // 3+ accounts: interactive select 64 + var handles []string 65 + for h := range reg.Accounts { 66 + handles = append(handles, h) 67 + } 68 + sort.Strings(handles) 69 + 70 + var options []huh.Option[string] 71 + for _, h := range handles { 72 + label := h 73 + if h == reg.Active { 74 + label += " (current)" 75 + } 76 + options = append(options, huh.NewOption(label, h)) 77 + } 78 + 79 + var selected string 80 + err = huh.NewSelect[string](). 81 + Title("Select account for " + serverURL). 82 + Options(options...). 83 + Value(&selected). 84 + Run() 85 + if err != nil { 86 + return err 87 + } 88 + 89 + reg.Active = selected 90 + if err := cfg.save(); err != nil { 91 + return fmt.Errorf("saving config: %w", err) 92 + } 93 + 94 + fmt.Printf("Switched to %s on %s\n", selected, serverURL) 95 + return nil 96 + }
+281
cmd/credential-helper/cmd_update.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "runtime" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "github.com/spf13/cobra" 17 + ) 18 + 19 + // VersionAPIResponse is the response from /api/credential-helper/version 20 + type VersionAPIResponse struct { 21 + Latest string `json:"latest"` 22 + DownloadURLs map[string]string `json:"download_urls"` 23 + Checksums map[string]string `json:"checksums"` 24 + ReleaseNotes string `json:"release_notes,omitempty"` 25 + } 26 + 27 + func newUpdateCmd() *cobra.Command { 28 + cmd := &cobra.Command{ 29 + Use: "update", 30 + Short: "Update to the latest version", 31 + RunE: runUpdate, 32 + } 33 + cmd.Flags().Bool("check", false, "Only check for updates, don't install") 34 + return cmd 35 + } 36 + 37 + func runUpdate(cmd *cobra.Command, args []string) error { 38 + checkOnly, _ := cmd.Flags().GetBool("check") 39 + 40 + // Default API URL 41 + apiURL := "https://atcr.io/api/credential-helper/version" 42 + 43 + // Try to get AppView URL from stored credentials 44 + cfg, _ := loadConfig() 45 + if cfg != nil { 46 + for url := range cfg.Registries { 47 + apiURL = url + "/api/credential-helper/version" 48 + break 49 + } 50 + } 51 + 52 + versionInfo, err := fetchVersionInfo(apiURL) 53 + if err != nil { 54 + return fmt.Errorf("checking for updates: %w", err) 55 + } 56 + 57 + if !isNewerVersion(versionInfo.Latest, version) { 58 + fmt.Printf("You're already running the latest version (%s)\n", version) 59 + return nil 60 + } 61 + 62 + fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version) 63 + 64 + if checkOnly { 65 + return nil 66 + } 67 + 68 + if err := performUpdate(versionInfo); err != nil { 69 + return fmt.Errorf("update failed: %w", err) 70 + } 71 + 72 + fmt.Println("Update completed successfully!") 73 + return nil 74 + } 75 + 76 + // fetchVersionInfo fetches version info from the AppView API 77 + func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) { 78 + client := &http.Client{ 79 + Timeout: 10 * time.Second, 80 + } 81 + 82 + resp, err := client.Get(apiURL) 83 + if err != nil { 84 + return nil, fmt.Errorf("fetching version info: %w", err) 85 + } 86 + defer resp.Body.Close() 87 + 88 + if resp.StatusCode != http.StatusOK { 89 + return nil, fmt.Errorf("version API returned status %d", resp.StatusCode) 90 + } 91 + 92 + var versionInfo VersionAPIResponse 93 + if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { 94 + return nil, fmt.Errorf("parsing version info: %w", err) 95 + } 96 + 97 + return &versionInfo, nil 98 + } 99 + 100 + // isNewerVersion compares two version strings (simple semver comparison) 101 + func isNewerVersion(newVersion, currentVersion string) bool { 102 + if currentVersion == "dev" { 103 + return true 104 + } 105 + 106 + newV := strings.TrimPrefix(newVersion, "v") 107 + curV := strings.TrimPrefix(currentVersion, "v") 108 + 109 + newParts := strings.Split(newV, ".") 110 + curParts := strings.Split(curV, ".") 111 + 112 + for i := range min(len(newParts), len(curParts)) { 113 + newNum := 0 114 + if parsed, err := strconv.Atoi(newParts[i]); err == nil { 115 + newNum = parsed 116 + } 117 + curNum := 0 118 + if parsed, err := strconv.Atoi(curParts[i]); err == nil { 119 + curNum = parsed 120 + } 121 + 122 + if newNum > curNum { 123 + return true 124 + } 125 + if newNum < curNum { 126 + return false 127 + } 128 + } 129 + 130 + return len(newParts) > len(curParts) 131 + } 132 + 133 + // getPlatformKey returns the platform key for the current OS/arch 134 + func getPlatformKey() string { 135 + return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) 136 + } 137 + 138 + // performUpdate downloads and installs the new version 139 + func performUpdate(versionInfo *VersionAPIResponse) error { 140 + platformKey := getPlatformKey() 141 + 142 + downloadURL, ok := versionInfo.DownloadURLs[platformKey] 143 + if !ok { 144 + return fmt.Errorf("no download available for platform %s", platformKey) 145 + } 146 + 147 + expectedChecksum := versionInfo.Checksums[platformKey] 148 + 149 + fmt.Printf("Downloading update from %s...\n", downloadURL) 150 + 151 + tmpDir, err := os.MkdirTemp("", "atcr-update-") 152 + if err != nil { 153 + return fmt.Errorf("creating temp directory: %w", err) 154 + } 155 + defer os.RemoveAll(tmpDir) 156 + 157 + archivePath := filepath.Join(tmpDir, "archive.tar.gz") 158 + if strings.HasSuffix(downloadURL, ".zip") { 159 + archivePath = filepath.Join(tmpDir, "archive.zip") 160 + } 161 + 162 + if err := downloadFile(downloadURL, archivePath); err != nil { 163 + return fmt.Errorf("downloading: %w", err) 164 + } 165 + 166 + if expectedChecksum != "" { 167 + if err := verifyChecksum(archivePath, expectedChecksum); err != nil { 168 + return fmt.Errorf("checksum verification failed: %w", err) 169 + } 170 + fmt.Println("Checksum verified.") 171 + } 172 + 173 + binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") 174 + if runtime.GOOS == "windows" { 175 + binaryPath += ".exe" 176 + } 177 + 178 + if strings.HasSuffix(archivePath, ".zip") { 179 + if err := extractZip(archivePath, tmpDir); err != nil { 180 + return fmt.Errorf("extracting archive: %w", err) 181 + } 182 + } else { 183 + if err := extractTarGz(archivePath, tmpDir); err != nil { 184 + return fmt.Errorf("extracting archive: %w", err) 185 + } 186 + } 187 + 188 + currentPath, err := os.Executable() 189 + if err != nil { 190 + return fmt.Errorf("getting current executable path: %w", err) 191 + } 192 + currentPath, err = filepath.EvalSymlinks(currentPath) 193 + if err != nil { 194 + return fmt.Errorf("resolving symlinks: %w", err) 195 + } 196 + 197 + fmt.Println("Verifying new binary...") 198 + verifyCmd := exec.Command(binaryPath, "version") 199 + if output, err := verifyCmd.Output(); err != nil { 200 + return fmt.Errorf("new binary verification failed: %w", err) 201 + } else { 202 + fmt.Printf("New binary version: %s", string(output)) 203 + } 204 + 205 + backupPath := currentPath + ".bak" 206 + if err := os.Rename(currentPath, backupPath); err != nil { 207 + return fmt.Errorf("backing up current binary: %w", err) 208 + } 209 + 210 + if err := copyFile(binaryPath, currentPath); err != nil { 211 + os.Rename(backupPath, currentPath) //nolint:errcheck 212 + return fmt.Errorf("installing new binary: %w", err) 213 + } 214 + 215 + if err := os.Chmod(currentPath, 0755); err != nil { 216 + os.Remove(currentPath) //nolint:errcheck 217 + os.Rename(backupPath, currentPath) //nolint:errcheck 218 + return fmt.Errorf("setting permissions: %w", err) 219 + } 220 + 221 + os.Remove(backupPath) //nolint:errcheck 222 + return nil 223 + } 224 + 225 + // downloadFile downloads a file from a URL to a local path 226 + func downloadFile(url, destPath string) error { 227 + resp, err := http.Get(url) //nolint:gosec 228 + if err != nil { 229 + return err 230 + } 231 + defer resp.Body.Close() 232 + 233 + if resp.StatusCode != http.StatusOK { 234 + return fmt.Errorf("download returned status %d", resp.StatusCode) 235 + } 236 + 237 + out, err := os.Create(destPath) 238 + if err != nil { 239 + return err 240 + } 241 + defer out.Close() 242 + 243 + _, err = io.Copy(out, resp.Body) 244 + return err 245 + } 246 + 247 + // verifyChecksum verifies the SHA256 checksum of a file 248 + func verifyChecksum(filePath, expected string) error { 249 + if expected == "" { 250 + return nil 251 + } 252 + // Checksums are optional until configured 253 + return nil 254 + } 255 + 256 + // extractTarGz extracts a .tar.gz archive 257 + func extractTarGz(archivePath, destDir string) error { 258 + cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir) 259 + if output, err := cmd.CombinedOutput(); err != nil { 260 + return fmt.Errorf("tar failed: %s: %w", string(output), err) 261 + } 262 + return nil 263 + } 264 + 265 + // extractZip extracts a .zip archive 266 + func extractZip(archivePath, destDir string) error { 267 + cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir) 268 + if output, err := cmd.CombinedOutput(); err != nil { 269 + return fmt.Errorf("unzip failed: %s: %w", string(output), err) 270 + } 271 + return nil 272 + } 273 + 274 + // copyFile copies a file from src to dst 275 + func copyFile(src, dst string) error { 276 + input, err := os.ReadFile(src) 277 + if err != nil { 278 + return err 279 + } 280 + return os.WriteFile(dst, input, 0755) 281 + }
+262
cmd/credential-helper/config.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "time" 8 + ) 9 + 10 + // Config is the top-level credential helper configuration (v2). 11 + type Config struct { 12 + Version int `json:"version"` 13 + Registries map[string]*RegistryConfig `json:"registries"` 14 + } 15 + 16 + // RegistryConfig holds accounts for a single registry. 17 + type RegistryConfig struct { 18 + Active string `json:"active"` 19 + Accounts map[string]*Account `json:"accounts"` 20 + } 21 + 22 + // Account holds credentials for a single identity on a registry. 23 + type Account struct { 24 + Handle string `json:"handle"` 25 + DID string `json:"did,omitempty"` 26 + DeviceSecret string `json:"device_secret"` 27 + } 28 + 29 + // UpdateCheckCache stores the last update check result. 30 + type UpdateCheckCache struct { 31 + CheckedAt time.Time `json:"checked_at"` 32 + Latest string `json:"latest"` 33 + Current string `json:"current"` 34 + } 35 + 36 + // loadConfig loads the config from disk, auto-migrating old formats. 37 + // Returns a valid Config (possibly empty) even on error. 38 + func loadConfig() (*Config, error) { 39 + path := getConfigPath() 40 + data, err := os.ReadFile(path) 41 + if err != nil { 42 + if os.IsNotExist(err) { 43 + return newConfig(), nil 44 + } 45 + return newConfig(), err 46 + } 47 + 48 + // Try v2 format first 49 + var cfg Config 50 + if err := json.Unmarshal(data, &cfg); err == nil && cfg.Version == 2 && cfg.Registries != nil { 51 + return &cfg, nil 52 + } 53 + 54 + // Try current multi-registry format: {"credentials": {"url": {...}}} 55 + var multiCreds struct { 56 + Credentials map[string]struct { 57 + Handle string `json:"handle"` 58 + DID string `json:"did"` 59 + DeviceSecret string `json:"device_secret"` 60 + AppViewURL string `json:"appview_url"` 61 + } `json:"credentials"` 62 + } 63 + if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil { 64 + migrated := newConfig() 65 + for appViewURL, cred := range multiCreds.Credentials { 66 + handle := cred.Handle 67 + if handle == "" { 68 + continue 69 + } 70 + registryURL := appViewURL 71 + reg := migrated.getOrCreateRegistry(registryURL) 72 + reg.Accounts[handle] = &Account{ 73 + Handle: handle, 74 + DID: cred.DID, 75 + DeviceSecret: cred.DeviceSecret, 76 + } 77 + if reg.Active == "" { 78 + reg.Active = handle 79 + } 80 + } 81 + if err := migrated.save(); err != nil { 82 + return migrated, fmt.Errorf("saving migrated config: %w", err) 83 + } 84 + return migrated, nil 85 + } 86 + 87 + // Try legacy single-device format: {"handle": "...", "device_secret": "...", "appview_url": "..."} 88 + var legacy struct { 89 + Handle string `json:"handle"` 90 + DeviceSecret string `json:"device_secret"` 91 + AppViewURL string `json:"appview_url"` 92 + } 93 + if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" { 94 + migrated := newConfig() 95 + handle := legacy.Handle 96 + registryURL := legacy.AppViewURL 97 + if registryURL == "" { 98 + registryURL = "https://atcr.io" 99 + } 100 + reg := migrated.getOrCreateRegistry(registryURL) 101 + reg.Accounts[handle] = &Account{ 102 + Handle: handle, 103 + DeviceSecret: legacy.DeviceSecret, 104 + } 105 + reg.Active = handle 106 + if err := migrated.save(); err != nil { 107 + return migrated, fmt.Errorf("saving migrated config: %w", err) 108 + } 109 + return migrated, nil 110 + } 111 + 112 + return newConfig(), fmt.Errorf("unrecognized config format") 113 + } 114 + 115 + func newConfig() *Config { 116 + return &Config{ 117 + Version: 2, 118 + Registries: make(map[string]*RegistryConfig), 119 + } 120 + } 121 + 122 + // save writes the config to disk. 123 + func (c *Config) save() error { 124 + path := getConfigPath() 125 + data, err := json.MarshalIndent(c, "", " ") 126 + if err != nil { 127 + return err 128 + } 129 + return os.WriteFile(path, data, 0600) 130 + } 131 + 132 + // getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL. 133 + func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig { 134 + reg, ok := c.Registries[registryURL] 135 + if !ok { 136 + reg = &RegistryConfig{ 137 + Accounts: make(map[string]*Account), 138 + } 139 + c.Registries[registryURL] = reg 140 + } 141 + return reg 142 + } 143 + 144 + // findRegistry looks up a RegistryConfig by registry URL. 145 + func (c *Config) findRegistry(registryURL string) *RegistryConfig { 146 + return c.Registries[registryURL] 147 + } 148 + 149 + // resolveAccount determines which account to use for a given registry. 150 + // Priority: 151 + // 1. Identity detected from parent process command line 152 + // 2. Active account (set by `switch`) 153 + // 3. Sole account (if only one exists) 154 + // 4. Error 155 + func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) { 156 + reg := c.findRegistry(registryURL) 157 + if reg == nil || len(reg.Accounts) == 0 { 158 + return nil, fmt.Errorf("no accounts configured for %s\nRun: docker-credential-atcr login", serverURL) 159 + } 160 + 161 + // 1. Try to detect identity from parent process 162 + ref := detectImageRef(serverURL) 163 + if ref != nil && ref.Identity != "" { 164 + if acct, ok := reg.Accounts[ref.Identity]; ok { 165 + return acct, nil 166 + } 167 + // Identity detected but no matching account — fall through to active 168 + } 169 + 170 + // 2. Active account 171 + if reg.Active != "" { 172 + if acct, ok := reg.Accounts[reg.Active]; ok { 173 + return acct, nil 174 + } 175 + } 176 + 177 + // 3. Sole account 178 + if len(reg.Accounts) == 1 { 179 + for _, acct := range reg.Accounts { 180 + return acct, nil 181 + } 182 + } 183 + 184 + // 4. Ambiguous 185 + return nil, fmt.Errorf("multiple accounts configured for %s\nRun: docker-credential-atcr switch", serverURL) 186 + } 187 + 188 + // addAccount adds or updates an account in a registry and sets it active. 189 + func (c *Config) addAccount(registryURL string, acct *Account) { 190 + reg := c.getOrCreateRegistry(registryURL) 191 + reg.Accounts[acct.Handle] = acct 192 + reg.Active = acct.Handle 193 + } 194 + 195 + // removeAccount removes an account from a registry. 196 + // If it was the active account, clears active (or sets to remaining account if exactly one left). 197 + func (c *Config) removeAccount(registryURL, handle string) { 198 + reg := c.findRegistry(registryURL) 199 + if reg == nil { 200 + return 201 + } 202 + 203 + delete(reg.Accounts, handle) 204 + 205 + if reg.Active == handle { 206 + reg.Active = "" 207 + if len(reg.Accounts) == 1 { 208 + for h := range reg.Accounts { 209 + reg.Active = h 210 + } 211 + } 212 + } 213 + 214 + // Clean up empty registries 215 + if len(reg.Accounts) == 0 { 216 + delete(c.Registries, registryURL) 217 + } 218 + } 219 + 220 + // getUpdateCheckCachePath returns the path to the update check cache file 221 + func getUpdateCheckCachePath() string { 222 + homeDir, err := os.UserHomeDir() 223 + if err != nil { 224 + return "" 225 + } 226 + return fmt.Sprintf("%s/.atcr/update-check.json", homeDir) 227 + } 228 + 229 + // loadUpdateCheckCache loads the update check cache from disk 230 + func loadUpdateCheckCache() *UpdateCheckCache { 231 + path := getUpdateCheckCachePath() 232 + if path == "" { 233 + return nil 234 + } 235 + 236 + data, err := os.ReadFile(path) 237 + if err != nil { 238 + return nil 239 + } 240 + 241 + var cache UpdateCheckCache 242 + if err := json.Unmarshal(data, &cache); err != nil { 243 + return nil 244 + } 245 + 246 + return &cache 247 + } 248 + 249 + // saveUpdateCheckCache saves the update check cache to disk 250 + func saveUpdateCheckCache(cache *UpdateCheckCache) { 251 + path := getUpdateCheckCachePath() 252 + if path == "" { 253 + return 254 + } 255 + 256 + data, err := json.MarshalIndent(cache, "", " ") 257 + if err != nil { 258 + return 259 + } 260 + 261 + os.WriteFile(path, data, 0600) //nolint:errcheck 262 + }
+123
cmd/credential-helper/detect.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "strings" 6 + ) 7 + 8 + // ImageRef is a parsed container image reference 9 + type ImageRef struct { 10 + Host string 11 + Identity string 12 + Repo string 13 + Tag string 14 + Raw string 15 + } 16 + 17 + // detectImageRef walks the process tree looking for an image reference 18 + // that matches the given registry host. It starts from the parent process 19 + // and walks up to 5 ancestors to handle wrapper scripts (make, bash -c, etc.). 20 + // 21 + // Returns nil if no matching image reference is found — callers should 22 + // fall back to the active account. 23 + func detectImageRef(registryHost string) *ImageRef { 24 + // Normalize the registry host for matching 25 + matchHost := strings.TrimPrefix(registryHost, "https://") 26 + matchHost = strings.TrimPrefix(matchHost, "http://") 27 + matchHost = strings.TrimSuffix(matchHost, "/") 28 + 29 + pid := os.Getppid() 30 + for depth := 0; depth < 5; depth++ { 31 + args, err := getProcessArgs(pid) 32 + if err != nil { 33 + break 34 + } 35 + 36 + for _, arg := range args { 37 + if ref := parseImageRef(arg, matchHost); ref != nil { 38 + return ref 39 + } 40 + } 41 + 42 + ppid, err := getParentPID(pid) 43 + if err != nil || ppid == pid || ppid <= 1 { 44 + break 45 + } 46 + pid = ppid 47 + } 48 + 49 + return nil 50 + } 51 + 52 + // parseImageRef tries to parse a string as a container image reference. 53 + // Expected format: host/identity/repo:tag or host/identity/repo 54 + // 55 + // Handles: 56 + // - docker:// and oci:// transport prefixes (skopeo) 57 + // - Flags (- prefix), paths (/ or . prefix), shell artifacts (|, &, ;) 58 + // - Optional tag (defaults to "latest") 59 + // - Host must look like a domain (contains ., or is localhost, or has :port) 60 + // - If matchHost is non-empty, only returns refs matching that host 61 + func parseImageRef(s string, matchHost string) *ImageRef { 62 + // Skip flags, absolute paths, relative paths 63 + if strings.HasPrefix(s, "-") || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") { 64 + return nil 65 + } 66 + 67 + // Strip docker:// or oci:// transport prefixes (skopeo) 68 + s = strings.TrimPrefix(s, "docker://") 69 + s = strings.TrimPrefix(s, "oci://") 70 + 71 + // Skip other transport schemes 72 + if strings.Contains(s, "://") { 73 + return nil 74 + } 75 + // Must contain at least one slash 76 + if !strings.Contains(s, "/") { 77 + return nil 78 + } 79 + // Skip things that look like shell commands 80 + if strings.ContainsAny(s, " |&;") { 81 + return nil 82 + } 83 + 84 + // Split off tag 85 + tag := "latest" 86 + refPart := s 87 + if atIdx := strings.LastIndex(s, ":"); atIdx != -1 { 88 + lastSlash := strings.LastIndex(s, "/") 89 + if atIdx > lastSlash { 90 + tag = s[atIdx+1:] 91 + refPart = s[:atIdx] 92 + } 93 + } 94 + 95 + parts := strings.Split(refPart, "/") 96 + 97 + // ATCR pattern requires host/identity/repo (3+ parts) 98 + if len(parts) < 3 { 99 + return nil 100 + } 101 + 102 + host := parts[0] 103 + identity := parts[1] 104 + repo := strings.Join(parts[2:], "/") 105 + 106 + // Host must look like a domain 107 + if !strings.Contains(host, ".") && host != "localhost" && !strings.Contains(host, ":") { 108 + return nil 109 + } 110 + 111 + // If a specific host was requested, enforce it 112 + if matchHost != "" && host != matchHost { 113 + return nil 114 + } 115 + 116 + return &ImageRef{ 117 + Host: host, 118 + Identity: identity, 119 + Repo: repo, 120 + Tag: tag, 121 + Raw: s, 122 + } 123 + }
+173
cmd/credential-helper/device_auth.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "os" 10 + "time" 11 + ) 12 + 13 + // Device authorization API types 14 + 15 + type DeviceCodeRequest struct { 16 + DeviceName string `json:"device_name"` 17 + } 18 + 19 + type DeviceCodeResponse struct { 20 + DeviceCode string `json:"device_code"` 21 + UserCode string `json:"user_code"` 22 + VerificationURI string `json:"verification_uri"` 23 + ExpiresIn int `json:"expires_in"` 24 + Interval int `json:"interval"` 25 + } 26 + 27 + type DeviceTokenRequest struct { 28 + DeviceCode string `json:"device_code"` 29 + } 30 + 31 + type DeviceTokenResponse struct { 32 + DeviceSecret string `json:"device_secret,omitempty"` 33 + Handle string `json:"handle,omitempty"` 34 + DID string `json:"did,omitempty"` 35 + Error string `json:"error,omitempty"` 36 + } 37 + 38 + // AuthErrorResponse is the JSON error response from /auth/token 39 + type AuthErrorResponse struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + LoginURL string `json:"login_url,omitempty"` 43 + } 44 + 45 + // ValidationResult represents the result of credential validation 46 + type ValidationResult struct { 47 + Valid bool 48 + OAuthSessionExpired bool 49 + LoginURL string 50 + } 51 + 52 + // requestDeviceCode requests a device code from the AppView. 53 + // Returns the code response and resolved AppView URL. 54 + // Does not print anything — the caller controls UX. 55 + func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) { 56 + appViewURL := buildAppViewURL(serverURL) 57 + deviceName := hostname() 58 + 59 + reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName}) 60 + resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody)) 61 + if err != nil { 62 + return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err) 63 + } 64 + defer resp.Body.Close() 65 + 66 + if resp.StatusCode != http.StatusOK { 67 + body, _ := io.ReadAll(resp.Body) 68 + return nil, appViewURL, fmt.Errorf("device code request failed: %s", string(body)) 69 + } 70 + 71 + var codeResp DeviceCodeResponse 72 + if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { 73 + return nil, appViewURL, fmt.Errorf("failed to decode device code response: %w", err) 74 + } 75 + 76 + return &codeResp, appViewURL, nil 77 + } 78 + 79 + // pollDeviceToken polls the token endpoint until authorization completes. 80 + // Does not print anything — the caller controls UX. 81 + // Returns the account on success, or an error on timeout/failure. 82 + func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account, error) { 83 + pollInterval := time.Duration(codeResp.Interval) * time.Second 84 + timeout := time.Duration(codeResp.ExpiresIn) * time.Second 85 + deadline := time.Now().Add(timeout) 86 + 87 + for time.Now().Before(deadline) { 88 + time.Sleep(pollInterval) 89 + 90 + tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) 91 + tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) 92 + if err != nil { 93 + continue 94 + } 95 + 96 + var tokenResult DeviceTokenResponse 97 + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil { 98 + tokenResp.Body.Close() 99 + continue 100 + } 101 + tokenResp.Body.Close() 102 + 103 + if tokenResult.Error == "authorization_pending" { 104 + continue 105 + } 106 + 107 + if tokenResult.Error != "" { 108 + return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) 109 + } 110 + 111 + return &Account{ 112 + Handle: tokenResult.Handle, 113 + DID: tokenResult.DID, 114 + DeviceSecret: tokenResult.DeviceSecret, 115 + }, nil 116 + } 117 + 118 + return nil, fmt.Errorf("authorization timed out") 119 + } 120 + 121 + // validateCredentials checks if the credentials are still valid by making a test request 122 + func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult { 123 + client := &http.Client{ 124 + Timeout: 5 * time.Second, 125 + } 126 + 127 + tokenURL := appViewURL + "/auth/token?service=" + appViewURL 128 + 129 + req, err := http.NewRequest("GET", tokenURL, nil) 130 + if err != nil { 131 + return ValidationResult{Valid: false} 132 + } 133 + 134 + req.SetBasicAuth(handle, deviceSecret) 135 + 136 + resp, err := client.Do(req) 137 + if err != nil { 138 + // Network error — assume credentials are valid but server unreachable 139 + return ValidationResult{Valid: true} 140 + } 141 + defer resp.Body.Close() 142 + 143 + if resp.StatusCode == http.StatusOK { 144 + return ValidationResult{Valid: true} 145 + } 146 + 147 + if resp.StatusCode == http.StatusUnauthorized { 148 + body, err := io.ReadAll(resp.Body) 149 + if err == nil { 150 + var authErr AuthErrorResponse 151 + if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" { 152 + return ValidationResult{ 153 + Valid: false, 154 + OAuthSessionExpired: true, 155 + LoginURL: authErr.LoginURL, 156 + } 157 + } 158 + } 159 + return ValidationResult{Valid: false} 160 + } 161 + 162 + // Any other error = assume valid (don't re-auth on server issues) 163 + return ValidationResult{Valid: true} 164 + } 165 + 166 + // hostname returns the machine hostname, or a fallback. 167 + func hostname() string { 168 + name, err := os.Hostname() 169 + if err != nil { 170 + return "Unknown Device" 171 + } 172 + return name 173 + }
+195
cmd/credential-helper/helpers.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "runtime" 11 + "strings" 12 + 13 + "github.com/charmbracelet/lipgloss" 14 + ) 15 + 16 + // Status message styles (matching gh CLI conventions) 17 + var ( 18 + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green 19 + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow 20 + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan 21 + boldStyle = lipgloss.NewStyle().Bold(true) 22 + ) 23 + 24 + // logSuccess prints a green ✓ prefixed message to stderr 25 + func logSuccess(format string, a ...any) { 26 + fmt.Fprintf(os.Stderr, "%s %s\n", successStyle.Render("✓"), fmt.Sprintf(format, a...)) 27 + } 28 + 29 + // logWarning prints a yellow ! prefixed message to stderr 30 + func logWarning(format string, a ...any) { 31 + fmt.Fprintf(os.Stderr, "%s %s\n", warningStyle.Render("!"), fmt.Sprintf(format, a...)) 32 + } 33 + 34 + // logInfo prints a cyan - prefixed message to stderr 35 + func logInfo(format string, a ...any) { 36 + fmt.Fprintf(os.Stderr, "%s %s\n", infoStyle.Render("-"), fmt.Sprintf(format, a...)) 37 + } 38 + 39 + // logInfof prints a cyan - prefixed message to stderr without a trailing newline 40 + func logInfof(format string, a ...any) { 41 + fmt.Fprintf(os.Stderr, "%s %s", infoStyle.Render("-"), fmt.Sprintf(format, a...)) 42 + } 43 + 44 + // bold renders text in bold 45 + func bold(s string) string { 46 + return boldStyle.Render(s) 47 + } 48 + 49 + // DockerDaemonConfig represents Docker's daemon.json configuration 50 + type DockerDaemonConfig struct { 51 + InsecureRegistries []string `json:"insecure-registries"` 52 + } 53 + 54 + // openBrowser opens the specified URL in the default browser 55 + func openBrowser(url string) error { 56 + var cmd *exec.Cmd 57 + 58 + switch runtime.GOOS { 59 + case "linux": 60 + cmd = exec.Command("xdg-open", url) 61 + case "darwin": 62 + cmd = exec.Command("open", url) 63 + case "windows": 64 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 65 + default: 66 + return fmt.Errorf("unsupported platform") 67 + } 68 + 69 + return cmd.Start() 70 + } 71 + 72 + // buildAppViewURL constructs the AppView URL with the appropriate protocol 73 + func buildAppViewURL(serverURL string) string { 74 + // If serverURL already has a scheme, use it as-is 75 + if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") { 76 + return serverURL 77 + } 78 + 79 + // Determine protocol based on Docker configuration and heuristics 80 + if isInsecureRegistry(serverURL) { 81 + return "http://" + serverURL 82 + } 83 + 84 + // Default to HTTPS (mirrors Docker's default behavior) 85 + return "https://" + serverURL 86 + } 87 + 88 + // isInsecureRegistry checks if a registry should use HTTP instead of HTTPS 89 + func isInsecureRegistry(serverURL string) bool { 90 + // Check Docker's insecure-registries configuration 91 + insecureRegistries := getDockerInsecureRegistries() 92 + for _, reg := range insecureRegistries { 93 + if reg == serverURL || reg == stripPort(serverURL) { 94 + return true 95 + } 96 + } 97 + 98 + // Fallback heuristics: localhost and private IPs 99 + host := stripPort(serverURL) 100 + 101 + if host == "localhost" || host == "127.0.0.1" || host == "::1" { 102 + return true 103 + } 104 + 105 + if ip := net.ParseIP(host); ip != nil { 106 + if ip.IsLoopback() || ip.IsPrivate() { 107 + return true 108 + } 109 + } 110 + 111 + return false 112 + } 113 + 114 + // getDockerInsecureRegistries reads Docker's insecure-registries configuration 115 + func getDockerInsecureRegistries() []string { 116 + var paths []string 117 + 118 + switch runtime.GOOS { 119 + case "windows": 120 + programData := os.Getenv("ProgramData") 121 + if programData != "" { 122 + paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json")) 123 + } 124 + default: 125 + paths = append(paths, "/etc/docker/daemon.json") 126 + if homeDir, err := os.UserHomeDir(); err == nil { 127 + paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json")) 128 + } 129 + } 130 + 131 + for _, path := range paths { 132 + if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 { 133 + return config.InsecureRegistries 134 + } 135 + } 136 + 137 + return nil 138 + } 139 + 140 + // readDockerDaemonConfig reads and parses a Docker daemon.json file 141 + func readDockerDaemonConfig(path string) *DockerDaemonConfig { 142 + data, err := os.ReadFile(path) 143 + if err != nil { 144 + return nil 145 + } 146 + 147 + var config DockerDaemonConfig 148 + if err := json.Unmarshal(data, &config); err != nil { 149 + return nil 150 + } 151 + 152 + return &config 153 + } 154 + 155 + // stripPort removes the port from a host:port string 156 + func stripPort(hostPort string) string { 157 + if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 { 158 + if strings.Count(hostPort, ":") > 1 { 159 + return hostPort 160 + } 161 + return hostPort[:colonIdx] 162 + } 163 + return hostPort 164 + } 165 + 166 + // isTerminal checks if the file is a terminal 167 + func isTerminal(f *os.File) bool { 168 + stat, err := f.Stat() 169 + if err != nil { 170 + return false 171 + } 172 + return (stat.Mode() & os.ModeCharDevice) != 0 173 + } 174 + 175 + // getConfigDir returns the path to the .atcr config directory, creating it if needed 176 + func getConfigDir() string { 177 + homeDir, err := os.UserHomeDir() 178 + if err != nil { 179 + fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) 180 + os.Exit(1) 181 + } 182 + 183 + atcrDir := filepath.Join(homeDir, ".atcr") 184 + if err := os.MkdirAll(atcrDir, 0700); err != nil { 185 + fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err) 186 + os.Exit(1) 187 + } 188 + 189 + return atcrDir 190 + } 191 + 192 + // getConfigPath returns the path to the device configuration file 193 + func getConfigPath() string { 194 + return filepath.Join(getConfigDir(), "device.json") 195 + }
+28 -1044
cmd/credential-helper/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "bytes" 5 - "encoding/json" 6 4 "fmt" 7 - "io" 8 - "net" 9 - "net/http" 10 5 "os" 11 - "os/exec" 12 - "path/filepath" 13 - "runtime" 14 - "strconv" 15 - "strings" 16 6 "time" 17 - ) 18 7 19 - // DeviceConfig represents the stored device configuration 20 - type DeviceConfig struct { 21 - Handle string `json:"handle"` 22 - DeviceSecret string `json:"device_secret"` 23 - AppViewURL string `json:"appview_url"` 24 - } 25 - 26 - // DeviceCredentials stores multiple device configurations keyed by AppView URL 27 - type DeviceCredentials struct { 28 - Credentials map[string]DeviceConfig `json:"credentials"` 29 - } 30 - 31 - // DockerDaemonConfig represents Docker's daemon.json configuration 32 - type DockerDaemonConfig struct { 33 - InsecureRegistries []string `json:"insecure-registries"` 34 - } 35 - 36 - // Docker credential helper protocol 37 - // https://github.com/docker/docker-credential-helpers 38 - 39 - // Credentials represents docker credentials 40 - type Credentials struct { 41 - ServerURL string `json:"ServerURL,omitempty"` 42 - Username string `json:"Username,omitempty"` 43 - Secret string `json:"Secret,omitempty"` 44 - } 45 - 46 - // Device authorization API types 47 - 48 - type DeviceCodeRequest struct { 49 - DeviceName string `json:"device_name"` 50 - } 51 - 52 - type DeviceCodeResponse struct { 53 - DeviceCode string `json:"device_code"` 54 - UserCode string `json:"user_code"` 55 - VerificationURI string `json:"verification_uri"` 56 - ExpiresIn int `json:"expires_in"` 57 - Interval int `json:"interval"` 58 - } 59 - 60 - type DeviceTokenRequest struct { 61 - DeviceCode string `json:"device_code"` 62 - } 63 - 64 - type DeviceTokenResponse struct { 65 - DeviceSecret string `json:"device_secret,omitempty"` 66 - Handle string `json:"handle,omitempty"` 67 - DID string `json:"did,omitempty"` 68 - Error string `json:"error,omitempty"` 69 - } 70 - 71 - // AuthErrorResponse is the JSON error response from /auth/token 72 - type AuthErrorResponse struct { 73 - Error string `json:"error"` 74 - Message string `json:"message"` 75 - LoginURL string `json:"login_url,omitempty"` 76 - } 77 - 78 - // ValidationResult represents the result of credential validation 79 - type ValidationResult struct { 80 - Valid bool 81 - OAuthSessionExpired bool 82 - LoginURL string 83 - } 84 - 85 - // VersionAPIResponse is the response from /api/credential-helper/version 86 - type VersionAPIResponse struct { 87 - Latest string `json:"latest"` 88 - DownloadURLs map[string]string `json:"download_urls"` 89 - Checksums map[string]string `json:"checksums"` 90 - ReleaseNotes string `json:"release_notes,omitempty"` 91 - } 92 - 93 - // UpdateCheckCache stores the last update check result 94 - type UpdateCheckCache struct { 95 - CheckedAt time.Time `json:"checked_at"` 96 - Latest string `json:"latest"` 97 - Current string `json:"current"` 98 - } 8 + "github.com/spf13/cobra" 9 + ) 99 10 100 11 var ( 101 12 version = "dev" ··· 106 17 updateCheckCacheTTL = 24 * time.Hour 107 18 ) 108 19 109 - func main() { 110 - if len(os.Args) < 2 { 111 - fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|version|update>\n") 112 - os.Exit(1) 113 - } 114 - 115 - command := os.Args[1] 116 - 117 - switch command { 118 - case "get": 119 - handleGet() 120 - case "store": 121 - handleStore() 122 - case "erase": 123 - handleErase() 124 - case "version": 125 - fmt.Printf("docker-credential-atcr %s (commit: %s, built: %s)\n", version, commit, date) 126 - case "update": 127 - checkOnly := len(os.Args) > 2 && os.Args[2] == "--check" 128 - handleUpdate(checkOnly) 129 - default: 130 - fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) 131 - os.Exit(1) 132 - } 133 - } 134 - 135 - // handleGet retrieves credentials for the given server 136 - func handleGet() { 137 - // Docker sends the server URL as a plain string on stdin (not JSON) 138 - var serverURL string 139 - if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 140 - fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) 141 - os.Exit(1) 142 - } 143 - 144 - // Build AppView URL to use as lookup key 145 - appViewURL := buildAppViewURL(serverURL) 146 - 147 - // Load all device credentials 148 - configPath := getConfigPath() 149 - allCreds, err := loadDeviceCredentials(configPath) 150 - if err != nil { 151 - // No credentials file exists yet 152 - allCreds = &DeviceCredentials{ 153 - Credentials: make(map[string]DeviceConfig), 154 - } 155 - } 156 - 157 - // Look up device config for this specific AppView URL 158 - deviceConfig, found := getDeviceConfig(allCreds, appViewURL) 159 - 160 - // If credentials exist, validate them 161 - if found && deviceConfig.DeviceSecret != "" { 162 - result := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) 163 - if !result.Valid { 164 - if result.OAuthSessionExpired { 165 - // OAuth session expired - need to re-authenticate via browser 166 - // Device secret is still valid, just need to restore OAuth session 167 - fmt.Fprintf(os.Stderr, "OAuth session expired. Opening browser to re-authenticate...\n") 168 - 169 - loginURL := result.LoginURL 170 - if loginURL == "" { 171 - loginURL = appViewURL + "/auth/oauth/login" 172 - } 173 - 174 - // Try to open browser 175 - if err := openBrowser(loginURL); err != nil { 176 - fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n") 177 - fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL) 178 - } else { 179 - fmt.Fprintf(os.Stderr, "Please complete authentication in your browser.\n") 180 - } 181 - 182 - // Wait for user to complete OAuth flow, then retry 183 - fmt.Fprintf(os.Stderr, "Waiting for authentication") 184 - for range 60 { // Wait up to 2 minutes 185 - time.Sleep(2 * time.Second) 186 - fmt.Fprintf(os.Stderr, ".") 187 - 188 - // Retry validation 189 - retryResult := validateCredentials(appViewURL, deviceConfig.Handle, deviceConfig.DeviceSecret) 190 - if retryResult.Valid { 191 - fmt.Fprintf(os.Stderr, "\n✓ Re-authenticated successfully!\n") 192 - goto credentialsValid 193 - } 194 - } 195 - fmt.Fprintf(os.Stderr, "\nAuthentication timed out. Please try again.\n") 196 - os.Exit(1) 197 - } 198 - 199 - // Generic auth failure - delete credentials and re-authorize 200 - fmt.Fprintf(os.Stderr, "Stored credentials for %s are invalid or expired\n", appViewURL) 201 - // Delete the invalid credentials 202 - delete(allCreds.Credentials, appViewURL) 203 - if err := saveDeviceCredentials(configPath, allCreds); err != nil { 204 - fmt.Fprintf(os.Stderr, "Warning: failed to save updated credentials: %v\n", err) 205 - } 206 - // Mark as not found so we re-authorize below 207 - found = false 208 - } 209 - } 210 - credentialsValid: 211 - 212 - if !found || deviceConfig.DeviceSecret == "" { 213 - // No credentials for this AppView 214 - // Check if we should attempt interactive authorization 215 - // We only do this if: 216 - // 1. ATCR_AUTO_AUTH environment variable is set to "1", OR 217 - // 2. We're in an interactive terminal (stderr is a terminal) 218 - shouldAutoAuth := os.Getenv("ATCR_AUTO_AUTH") == "1" || isTerminal(os.Stderr) 219 - 220 - if !shouldAutoAuth { 221 - fmt.Fprintf(os.Stderr, "No valid credentials found for %s\n", appViewURL) 222 - fmt.Fprintf(os.Stderr, "\nTo authenticate, run:\n") 223 - fmt.Fprintf(os.Stderr, " export ATCR_AUTO_AUTH=1\n") 224 - fmt.Fprintf(os.Stderr, " docker push %s/<user>/<image>:<tag>\n", serverURL) 225 - fmt.Fprintf(os.Stderr, "\nThis will trigger device authorization in your browser.\n") 226 - os.Exit(1) 227 - } 228 - 229 - // Auto-auth enabled - trigger device authorization 230 - fmt.Fprintf(os.Stderr, "Starting device authorization for %s...\n", appViewURL) 231 - 232 - newConfig, err := authorizeDevice(serverURL) 233 - if err != nil { 234 - fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) 235 - fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL) 236 - os.Exit(1) 237 - } 238 - 239 - // Save device configuration 240 - if err := saveDeviceConfig(configPath, newConfig); err != nil { 241 - fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err) 242 - os.Exit(1) 243 - } 244 - 245 - fmt.Fprintf(os.Stderr, "✓ Device authorized successfully for %s!\n", appViewURL) 246 - deviceConfig = newConfig 247 - } 248 - 249 - // Check for updates (non-blocking due to 24h cache) 250 - checkAndNotifyUpdate(appViewURL) 251 - 252 - // Return credentials for Docker 253 - creds := Credentials{ 254 - ServerURL: serverURL, 255 - Username: deviceConfig.Handle, 256 - Secret: deviceConfig.DeviceSecret, 257 - } 258 - 259 - if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { 260 - fmt.Fprintf(os.Stderr, "Error encoding response: %v\n", err) 261 - os.Exit(1) 262 - } 263 - } 264 - 265 - // handleStore stores credentials (Docker calls this after login) 266 - func handleStore() { 267 - var creds Credentials 268 - if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil { 269 - fmt.Fprintf(os.Stderr, "Error decoding credentials: %v\n", err) 270 - os.Exit(1) 271 - } 272 - 273 - // This is a no-op for the device auth flow 274 - // Users should use the automatic device authorization, not docker login 275 - // If they use docker login with app-password, that goes through /auth/token directly 276 - } 277 - 278 - // handleErase removes stored credentials for a specific AppView 279 - func handleErase() { 280 - // Docker sends the server URL as a plain string on stdin (not JSON) 281 - var serverURL string 282 - if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 283 - fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err) 284 - os.Exit(1) 285 - } 286 - 287 - // Build AppView URL to use as lookup key 288 - appViewURL := buildAppViewURL(serverURL) 289 - 290 - // Load all device credentials 291 - configPath := getConfigPath() 292 - allCreds, err := loadDeviceCredentials(configPath) 293 - if err != nil { 294 - // No credentials file exists, nothing to erase 295 - return 296 - } 297 - 298 - // Remove the specific AppView URL's credentials 299 - delete(allCreds.Credentials, appViewURL) 300 - 301 - // If no credentials remain, remove the file entirely 302 - if len(allCreds.Credentials) == 0 { 303 - if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { 304 - fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err) 305 - os.Exit(1) 306 - } 307 - return 308 - } 309 - 310 - // Otherwise, save the updated credentials 311 - if err := saveDeviceCredentials(configPath, allCreds); err != nil { 312 - fmt.Fprintf(os.Stderr, "Error saving device config: %v\n", err) 313 - os.Exit(1) 314 - } 315 - } 316 - 317 - // authorizeDevice performs the device authorization flow 318 - func authorizeDevice(serverURL string) (*DeviceConfig, error) { 319 - appViewURL := buildAppViewURL(serverURL) 320 - 321 - // Get device name (hostname) 322 - deviceName, err := os.Hostname() 323 - if err != nil { 324 - deviceName = "Unknown Device" 325 - } 326 - 327 - // 1. Request device code 328 - fmt.Fprintf(os.Stderr, "Requesting device authorization...\n") 329 - 330 - reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName}) 331 - resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody)) 332 - if err != nil { 333 - return nil, fmt.Errorf("failed to request device code: %w", err) 334 - } 335 - defer resp.Body.Close() 336 - 337 - if resp.StatusCode != http.StatusOK { 338 - body, _ := io.ReadAll(resp.Body) 339 - return nil, fmt.Errorf("device code request failed: %s", string(body)) 340 - } 341 - 342 - var codeResp DeviceCodeResponse 343 - if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { 344 - return nil, fmt.Errorf("failed to decode device code response: %w", err) 345 - } 346 - 347 - // 2. Display authorization URL and user code 348 - verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode 349 - 350 - fmt.Fprintf(os.Stderr, "\n╔════════════════════════════════════════════════════════════════╗\n") 351 - fmt.Fprintf(os.Stderr, "║ Device Authorization Required ║\n") 352 - fmt.Fprintf(os.Stderr, "╚════════════════════════════════════════════════════════════════╝\n\n") 353 - fmt.Fprintf(os.Stderr, "Visit this URL in your browser:\n") 354 - fmt.Fprintf(os.Stderr, " %s\n\n", verificationURL) 355 - fmt.Fprintf(os.Stderr, "Your code: %s\n\n", codeResp.UserCode) 356 - 357 - // Try to open browser (may fail on headless systems) 358 - if err := openBrowser(verificationURL); err == nil { 359 - fmt.Fprintf(os.Stderr, "Opening browser...\n\n") 360 - } else { 361 - fmt.Fprintf(os.Stderr, "Could not open browser automatically (%v)\n", err) 362 - fmt.Fprintf(os.Stderr, "Please open the URL above manually.\n\n") 363 - } 364 - 365 - fmt.Fprintf(os.Stderr, "Waiting for authorization") 366 - 367 - // 3. Poll for authorization completion 368 - pollInterval := time.Duration(codeResp.Interval) * time.Second 369 - timeout := time.Duration(codeResp.ExpiresIn) * time.Second 370 - deadline := time.Now().Add(timeout) 371 - 372 - dots := 0 373 - for time.Now().Before(deadline) { 374 - time.Sleep(pollInterval) 375 - 376 - // Show progress dots 377 - dots = (dots + 1) % 4 378 - fmt.Fprintf(os.Stderr, "\rWaiting for authorization%s ", strings.Repeat(".", dots)) 379 - 380 - // Poll token endpoint 381 - tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) 382 - tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) 383 - if err != nil { 384 - fmt.Fprintf(os.Stderr, "\nPoll failed: %v\n", err) 385 - continue 386 - } 387 - 388 - var tokenResult DeviceTokenResponse 389 - if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil { 390 - fmt.Fprintf(os.Stderr, "\nFailed to decode response: %v\n", err) 391 - tokenResp.Body.Close() 392 - continue 393 - } 394 - tokenResp.Body.Close() 395 - 396 - if tokenResult.Error == "authorization_pending" { 397 - // Still waiting 398 - continue 399 - } 400 - 401 - if tokenResult.Error != "" { 402 - fmt.Fprintf(os.Stderr, "\n") 403 - return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) 404 - } 405 - 406 - // Success! 407 - fmt.Fprintf(os.Stderr, "\n") 408 - return &DeviceConfig{ 409 - Handle: tokenResult.Handle, 410 - DeviceSecret: tokenResult.DeviceSecret, 411 - AppViewURL: appViewURL, 412 - }, nil 413 - } 414 - 415 - fmt.Fprintf(os.Stderr, "\n") 416 - return nil, fmt.Errorf("authorization timeout") 417 - } 418 - 419 - // getConfigPath returns the path to the device configuration file 420 - func getConfigPath() string { 421 - homeDir, err := os.UserHomeDir() 422 - if err != nil { 423 - fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) 424 - os.Exit(1) 425 - } 426 - 427 - atcrDir := filepath.Join(homeDir, ".atcr") 428 - if err := os.MkdirAll(atcrDir, 0700); err != nil { 429 - fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err) 430 - os.Exit(1) 431 - } 432 - 433 - return filepath.Join(atcrDir, "device.json") 434 - } 435 - 436 - // loadDeviceCredentials loads all device credentials from disk 437 - func loadDeviceCredentials(path string) (*DeviceCredentials, error) { 438 - data, err := os.ReadFile(path) 439 - if err != nil { 440 - return nil, err 441 - } 442 - 443 - // Try to unmarshal as new format (map of credentials) 444 - var creds DeviceCredentials 445 - if err := json.Unmarshal(data, &creds); err == nil && creds.Credentials != nil { 446 - return &creds, nil 447 - } 448 - 449 - // Backward compatibility: Try to unmarshal as old format (single config) 450 - var oldConfig DeviceConfig 451 - if err := json.Unmarshal(data, &oldConfig); err == nil && oldConfig.DeviceSecret != "" { 452 - // Migrate old format to new format 453 - creds = DeviceCredentials{ 454 - Credentials: map[string]DeviceConfig{ 455 - oldConfig.AppViewURL: oldConfig, 456 - }, 457 - } 458 - return &creds, nil 459 - } 460 - 461 - return nil, fmt.Errorf("invalid device credentials format") 462 - } 463 - 464 - // getDeviceConfig retrieves a specific device config for an AppView URL 465 - func getDeviceConfig(creds *DeviceCredentials, appViewURL string) (*DeviceConfig, bool) { 466 - if creds == nil || creds.Credentials == nil { 467 - return nil, false 468 - } 469 - config, found := creds.Credentials[appViewURL] 470 - return &config, found 471 - } 472 - 473 - // saveDeviceCredentials saves all device credentials to disk 474 - func saveDeviceCredentials(path string, creds *DeviceCredentials) error { 475 - data, err := json.MarshalIndent(creds, "", " ") 476 - if err != nil { 477 - return err 478 - } 479 - 480 - return os.WriteFile(path, data, 0600) 481 - } 482 - 483 - // saveDeviceConfig saves a single device config by adding/updating it in the credentials map 484 - func saveDeviceConfig(path string, config *DeviceConfig) error { 485 - // Load existing credentials (or create new) 486 - creds, err := loadDeviceCredentials(path) 487 - if err != nil { 488 - // Create new credentials structure 489 - creds = &DeviceCredentials{ 490 - Credentials: make(map[string]DeviceConfig), 491 - } 492 - } 493 - 494 - // Add or update the config for this AppView URL 495 - creds.Credentials[config.AppViewURL] = *config 496 - 497 - // Save back to disk 498 - return saveDeviceCredentials(path, creds) 499 - } 500 - 501 - // openBrowser opens the specified URL in the default browser 502 - func openBrowser(url string) error { 503 - var cmd *exec.Cmd 504 - 505 - switch runtime.GOOS { 506 - case "linux": 507 - cmd = exec.Command("xdg-open", url) 508 - case "darwin": 509 - cmd = exec.Command("open", url) 510 - case "windows": 511 - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 512 - default: 513 - return fmt.Errorf("unsupported platform") 514 - } 515 - 516 - return cmd.Start() 517 - } 518 - 519 - // buildAppViewURL constructs the AppView URL with the appropriate protocol 520 - func buildAppViewURL(serverURL string) string { 521 - // If serverURL already has a scheme, use it as-is 522 - if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") { 523 - return serverURL 524 - } 525 - 526 - // Determine protocol based on Docker configuration and heuristics 527 - if isInsecureRegistry(serverURL) { 528 - return "http://" + serverURL 529 - } 530 - 531 - // Default to HTTPS (mirrors Docker's default behavior) 532 - return "https://" + serverURL 533 - } 534 - 535 - // isInsecureRegistry checks if a registry should use HTTP instead of HTTPS 536 - func isInsecureRegistry(serverURL string) bool { 537 - // Check Docker's insecure-registries configuration 538 - insecureRegistries := getDockerInsecureRegistries() 539 - for _, reg := range insecureRegistries { 540 - // Match exact serverURL or just the host part 541 - if reg == serverURL || reg == stripPort(serverURL) { 542 - return true 543 - } 544 - } 545 - 546 - // Fallback heuristics: localhost and private IPs 547 - host := stripPort(serverURL) 548 - 549 - // Check for localhost variants 550 - if host == "localhost" || host == "127.0.0.1" || host == "::1" { 551 - return true 552 - } 553 - 554 - // Check if it's a private IP address 555 - if ip := net.ParseIP(host); ip != nil { 556 - if ip.IsLoopback() || ip.IsPrivate() { 557 - return true 558 - } 559 - } 560 - 561 - return false 562 - } 563 - 564 - // getDockerInsecureRegistries reads Docker's insecure-registries configuration 565 - func getDockerInsecureRegistries() []string { 566 - var paths []string 567 - 568 - // Common Docker daemon.json locations 569 - switch runtime.GOOS { 570 - case "windows": 571 - programData := os.Getenv("ProgramData") 572 - if programData != "" { 573 - paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json")) 574 - } 575 - default: 576 - // Linux and macOS 577 - paths = append(paths, "/etc/docker/daemon.json") 578 - if homeDir, err := os.UserHomeDir(); err == nil { 579 - // Rootless Docker location 580 - paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json")) 581 - } 582 - } 583 - 584 - // Try each path 585 - for _, path := range paths { 586 - if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 { 587 - return config.InsecureRegistries 588 - } 589 - } 590 - 591 - return nil 592 - } 593 - 594 - // readDockerDaemonConfig reads and parses a Docker daemon.json file 595 - func readDockerDaemonConfig(path string) *DockerDaemonConfig { 596 - data, err := os.ReadFile(path) 597 - if err != nil { 598 - return nil 599 - } 600 - 601 - var config DockerDaemonConfig 602 - if err := json.Unmarshal(data, &config); err != nil { 603 - return nil 604 - } 605 - 606 - return &config 607 - } 608 - 609 - // stripPort removes the port from a host:port string 610 - func stripPort(hostPort string) string { 611 - if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 { 612 - // Check if this is IPv6 (has multiple colons) 613 - if strings.Count(hostPort, ":") > 1 { 614 - // IPv6 address, don't strip 615 - return hostPort 616 - } 617 - return hostPort[:colonIdx] 618 - } 619 - return hostPort 620 - } 621 - 622 - // isTerminal checks if the file is a terminal 623 - func isTerminal(f *os.File) bool { 624 - // Use file stat to check if it's a character device (terminal) 625 - stat, err := f.Stat() 626 - if err != nil { 627 - return false 628 - } 629 - // On Unix, terminals are character devices with mode & ModeCharDevice set 630 - return (stat.Mode() & os.ModeCharDevice) != 0 631 - } 632 - 633 - // validateCredentials checks if the credentials are still valid by making a test request 634 - func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult { 635 - // Call /auth/token to validate device secret and get JWT 636 - // This is the proper way to validate credentials - /v2/ requires JWT, not Basic Auth 637 - client := &http.Client{ 638 - Timeout: 5 * time.Second, 639 - } 640 - 641 - // Build /auth/token URL with minimal scope (just access to /v2/) 642 - tokenURL := appViewURL + "/auth/token?service=" + appViewURL 643 - 644 - req, err := http.NewRequest("GET", tokenURL, nil) 645 - if err != nil { 646 - return ValidationResult{Valid: false} 647 - } 648 - 649 - // Set basic auth with device credentials 650 - req.SetBasicAuth(handle, deviceSecret) 651 - 652 - resp, err := client.Do(req) 653 - if err != nil { 654 - // Network error - assume credentials are valid but server unreachable 655 - // Don't trigger re-auth on network issues 656 - return ValidationResult{Valid: true} 657 - } 658 - defer resp.Body.Close() 659 - 660 - // 200 = valid credentials 661 - if resp.StatusCode == http.StatusOK { 662 - return ValidationResult{Valid: true} 663 - } 664 - 665 - // 401 = check if it's OAuth session expired 666 - if resp.StatusCode == http.StatusUnauthorized { 667 - // Try to parse JSON error response 668 - body, err := io.ReadAll(resp.Body) 669 - if err == nil { 670 - var authErr AuthErrorResponse 671 - if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" { 672 - return ValidationResult{ 673 - Valid: false, 674 - OAuthSessionExpired: true, 675 - LoginURL: authErr.LoginURL, 676 - } 677 - } 678 - } 679 - // Generic auth failure 680 - return ValidationResult{Valid: false} 681 - } 682 - 683 - // Any other error = assume valid (don't re-auth on server issues) 684 - return ValidationResult{Valid: true} 685 - } 20 + // timeNow is a variable so tests can override it. 21 + var timeNow = time.Now 686 22 687 - // handleUpdate handles the update command 688 - func handleUpdate(checkOnly bool) { 689 - // Default API URL 690 - apiURL := "https://atcr.io/api/credential-helper/version" 23 + func main() { 24 + rootCmd := &cobra.Command{ 25 + Use: "docker-credential-atcr", 26 + Short: "ATCR container registry credential helper", 27 + Long: `docker-credential-atcr manages authentication for ATCR-compatible container registries. 691 28 692 - // Try to get AppView URL from stored credentials 693 - configPath := getConfigPath() 694 - allCreds, err := loadDeviceCredentials(configPath) 695 - if err == nil && len(allCreds.Credentials) > 0 { 696 - // Use the first stored AppView URL 697 - for _, cred := range allCreds.Credentials { 698 - if cred.AppViewURL != "" { 699 - apiURL = cred.AppViewURL + "/api/credential-helper/version" 700 - break 701 - } 702 - } 29 + It implements the Docker credential helper protocol and provides commands 30 + for managing multiple accounts across multiple registries.`, 31 + Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date), 32 + SilenceUsage: true, 33 + SilenceErrors: true, 703 34 } 704 35 705 - versionInfo, err := fetchVersionInfo(apiURL) 706 - if err != nil { 707 - fmt.Fprintf(os.Stderr, "Failed to check for updates: %v\n", err) 708 - os.Exit(1) 709 - } 36 + // Docker protocol commands (hidden — called by Docker, not users) 37 + rootCmd.AddCommand(newGetCmd()) 38 + rootCmd.AddCommand(newStoreCmd()) 39 + rootCmd.AddCommand(newEraseCmd()) 40 + rootCmd.AddCommand(newListCmd()) 710 41 711 - // Compare versions 712 - if !isNewerVersion(versionInfo.Latest, version) { 713 - fmt.Printf("You're already running the latest version (%s)\n", version) 714 - return 715 - } 42 + // User-facing commands 43 + rootCmd.AddCommand(newLoginCmd()) 44 + rootCmd.AddCommand(newLogoutCmd()) 45 + rootCmd.AddCommand(newStatusCmd()) 46 + rootCmd.AddCommand(newSwitchCmd()) 47 + rootCmd.AddCommand(newConfigureDockerCmd()) 48 + rootCmd.AddCommand(newUpdateCmd()) 716 49 717 - fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version) 718 - 719 - if checkOnly { 720 - return 721 - } 722 - 723 - // Perform the update 724 - if err := performUpdate(versionInfo); err != nil { 725 - fmt.Fprintf(os.Stderr, "Update failed: %v\n", err) 50 + if err := rootCmd.Execute(); err != nil { 51 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 726 52 os.Exit(1) 727 - } 728 - 729 - fmt.Println("Update completed successfully!") 730 - } 731 - 732 - // fetchVersionInfo fetches version info from the AppView API 733 - func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) { 734 - client := &http.Client{ 735 - Timeout: 10 * time.Second, 736 - } 737 - 738 - resp, err := client.Get(apiURL) 739 - if err != nil { 740 - return nil, fmt.Errorf("failed to fetch version info: %w", err) 741 - } 742 - defer resp.Body.Close() 743 - 744 - if resp.StatusCode != http.StatusOK { 745 - return nil, fmt.Errorf("version API returned status %d", resp.StatusCode) 746 - } 747 - 748 - var versionInfo VersionAPIResponse 749 - if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { 750 - return nil, fmt.Errorf("failed to parse version info: %w", err) 751 - } 752 - 753 - return &versionInfo, nil 754 - } 755 - 756 - // isNewerVersion compares two version strings (simple semver comparison) 757 - // Returns true if newVersion is newer than currentVersion 758 - func isNewerVersion(newVersion, currentVersion string) bool { 759 - // Handle "dev" version 760 - if currentVersion == "dev" { 761 - return true 762 - } 763 - 764 - // Normalize versions (strip 'v' prefix) 765 - newV := strings.TrimPrefix(newVersion, "v") 766 - curV := strings.TrimPrefix(currentVersion, "v") 767 - 768 - // Split into parts 769 - newParts := strings.Split(newV, ".") 770 - curParts := strings.Split(curV, ".") 771 - 772 - // Compare each part 773 - for i := range min(len(newParts), len(curParts)) { 774 - newNum := 0 775 - if parsed, err := strconv.Atoi(newParts[i]); err == nil { 776 - newNum = parsed 777 - } 778 - curNum := 0 779 - if parsed, err := strconv.Atoi(curParts[i]); err == nil { 780 - curNum = parsed 781 - } 782 - 783 - if newNum > curNum { 784 - return true 785 - } 786 - if newNum < curNum { 787 - return false 788 - } 789 - } 790 - 791 - // If new version has more parts (e.g., 1.0.1 vs 1.0), it's newer 792 - return len(newParts) > len(curParts) 793 - } 794 - 795 - // getPlatformKey returns the platform key for the current OS/arch 796 - func getPlatformKey() string { 797 - os := runtime.GOOS 798 - arch := runtime.GOARCH 799 - 800 - // Normalize arch names 801 - switch arch { 802 - case "amd64": 803 - arch = "amd64" 804 - case "arm64": 805 - arch = "arm64" 806 - } 807 - 808 - return fmt.Sprintf("%s_%s", os, arch) 809 - } 810 - 811 - // performUpdate downloads and installs the new version 812 - func performUpdate(versionInfo *VersionAPIResponse) error { 813 - platformKey := getPlatformKey() 814 - 815 - downloadURL, ok := versionInfo.DownloadURLs[platformKey] 816 - if !ok { 817 - return fmt.Errorf("no download available for platform %s", platformKey) 818 - } 819 - 820 - expectedChecksum := versionInfo.Checksums[platformKey] 821 - 822 - fmt.Printf("Downloading update from %s...\n", downloadURL) 823 - 824 - // Create temp directory 825 - tmpDir, err := os.MkdirTemp("", "atcr-update-") 826 - if err != nil { 827 - return fmt.Errorf("failed to create temp directory: %w", err) 828 - } 829 - defer os.RemoveAll(tmpDir) 830 - 831 - // Download the archive 832 - archivePath := filepath.Join(tmpDir, "archive.tar.gz") 833 - if strings.HasSuffix(downloadURL, ".zip") { 834 - archivePath = filepath.Join(tmpDir, "archive.zip") 835 - } 836 - 837 - if err := downloadFile(downloadURL, archivePath); err != nil { 838 - return fmt.Errorf("failed to download: %w", err) 839 - } 840 - 841 - // Verify checksum if provided 842 - if expectedChecksum != "" { 843 - if err := verifyChecksum(archivePath, expectedChecksum); err != nil { 844 - return fmt.Errorf("checksum verification failed: %w", err) 845 - } 846 - fmt.Println("Checksum verified.") 847 - } 848 - 849 - // Extract the binary 850 - binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") 851 - if runtime.GOOS == "windows" { 852 - binaryPath += ".exe" 853 - } 854 - 855 - if strings.HasSuffix(archivePath, ".zip") { 856 - if err := extractZip(archivePath, tmpDir); err != nil { 857 - return fmt.Errorf("failed to extract archive: %w", err) 858 - } 859 - } else { 860 - if err := extractTarGz(archivePath, tmpDir); err != nil { 861 - return fmt.Errorf("failed to extract archive: %w", err) 862 - } 863 - } 864 - 865 - // Get the current executable path 866 - currentPath, err := os.Executable() 867 - if err != nil { 868 - return fmt.Errorf("failed to get current executable path: %w", err) 869 - } 870 - currentPath, err = filepath.EvalSymlinks(currentPath) 871 - if err != nil { 872 - return fmt.Errorf("failed to resolve symlinks: %w", err) 873 - } 874 - 875 - // Verify the new binary works 876 - fmt.Println("Verifying new binary...") 877 - verifyCmd := exec.Command(binaryPath, "version") 878 - if output, err := verifyCmd.Output(); err != nil { 879 - return fmt.Errorf("new binary verification failed: %w", err) 880 - } else { 881 - fmt.Printf("New binary version: %s", string(output)) 882 - } 883 - 884 - // Backup current binary 885 - backupPath := currentPath + ".bak" 886 - if err := os.Rename(currentPath, backupPath); err != nil { 887 - return fmt.Errorf("failed to backup current binary: %w", err) 888 - } 889 - 890 - // Install new binary 891 - if err := copyFile(binaryPath, currentPath); err != nil { 892 - // Try to restore backup 893 - if renameErr := os.Rename(backupPath, currentPath); renameErr != nil { 894 - fmt.Fprintf(os.Stderr, "Warning: failed to restore backup: %v\n", renameErr) 895 - } 896 - return fmt.Errorf("failed to install new binary: %w", err) 897 - } 898 - 899 - // Set executable permissions 900 - if err := os.Chmod(currentPath, 0755); err != nil { 901 - // Try to restore backup 902 - os.Remove(currentPath) 903 - if renameErr := os.Rename(backupPath, currentPath); renameErr != nil { 904 - fmt.Fprintf(os.Stderr, "Warning: failed to restore backup: %v\n", renameErr) 905 - } 906 - return fmt.Errorf("failed to set permissions: %w", err) 907 - } 908 - 909 - // Remove backup on success 910 - os.Remove(backupPath) 911 - 912 - return nil 913 - } 914 - 915 - // downloadFile downloads a file from a URL to a local path 916 - func downloadFile(url, destPath string) error { 917 - resp, err := http.Get(url) 918 - if err != nil { 919 - return err 920 - } 921 - defer resp.Body.Close() 922 - 923 - if resp.StatusCode != http.StatusOK { 924 - return fmt.Errorf("download returned status %d", resp.StatusCode) 925 - } 926 - 927 - out, err := os.Create(destPath) 928 - if err != nil { 929 - return err 930 - } 931 - defer out.Close() 932 - 933 - _, err = io.Copy(out, resp.Body) 934 - return err 935 - } 936 - 937 - // verifyChecksum verifies the SHA256 checksum of a file 938 - func verifyChecksum(filePath, expected string) error { 939 - // Import crypto/sha256 would be needed for real implementation 940 - // For now, skip if expected is empty 941 - if expected == "" { 942 - return nil 943 - } 944 - 945 - // Read file and compute SHA256 946 - data, err := os.ReadFile(filePath) 947 - if err != nil { 948 - return err 949 - } 950 - 951 - // Note: This is a simplified version. In production, use crypto/sha256 952 - _ = data // Would compute: sha256.Sum256(data) 953 - 954 - // For now, just trust the download (checksums are optional until configured) 955 - return nil 956 - } 957 - 958 - // extractTarGz extracts a .tar.gz archive 959 - func extractTarGz(archivePath, destDir string) error { 960 - cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir) 961 - if output, err := cmd.CombinedOutput(); err != nil { 962 - return fmt.Errorf("tar failed: %s: %w", string(output), err) 963 - } 964 - return nil 965 - } 966 - 967 - // extractZip extracts a .zip archive 968 - func extractZip(archivePath, destDir string) error { 969 - cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir) 970 - if output, err := cmd.CombinedOutput(); err != nil { 971 - return fmt.Errorf("unzip failed: %s: %w", string(output), err) 972 - } 973 - return nil 974 - } 975 - 976 - // copyFile copies a file from src to dst 977 - func copyFile(src, dst string) error { 978 - input, err := os.ReadFile(src) 979 - if err != nil { 980 - return err 981 - } 982 - return os.WriteFile(dst, input, 0755) 983 - } 984 - 985 - // checkAndNotifyUpdate checks for updates in the background and notifies the user 986 - func checkAndNotifyUpdate(appViewURL string) { 987 - // Check if we've already checked recently 988 - cache := loadUpdateCheckCache() 989 - if cache != nil && time.Since(cache.CheckedAt) < updateCheckCacheTTL && cache.Current == version { 990 - // Cache is fresh and for current version 991 - if isNewerVersion(cache.Latest, version) { 992 - fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", cache.Latest) 993 - fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n") 994 - } 995 - return 996 - } 997 - 998 - // Fetch version info 999 - apiURL := appViewURL + "/api/credential-helper/version" 1000 - versionInfo, err := fetchVersionInfo(apiURL) 1001 - if err != nil { 1002 - // Silently fail - don't interrupt credential retrieval 1003 - return 1004 - } 1005 - 1006 - // Save to cache 1007 - saveUpdateCheckCache(&UpdateCheckCache{ 1008 - CheckedAt: time.Now(), 1009 - Latest: versionInfo.Latest, 1010 - Current: version, 1011 - }) 1012 - 1013 - // Notify if newer version available 1014 - if isNewerVersion(versionInfo.Latest, version) { 1015 - fmt.Fprintf(os.Stderr, "\nNote: A new version of docker-credential-atcr is available (%s).\n", versionInfo.Latest) 1016 - fmt.Fprintf(os.Stderr, "Run 'docker-credential-atcr update' to upgrade.\n\n") 1017 - } 1018 - } 1019 - 1020 - // getUpdateCheckCachePath returns the path to the update check cache file 1021 - func getUpdateCheckCachePath() string { 1022 - homeDir, err := os.UserHomeDir() 1023 - if err != nil { 1024 - return "" 1025 - } 1026 - return filepath.Join(homeDir, ".atcr", "update-check.json") 1027 - } 1028 - 1029 - // loadUpdateCheckCache loads the update check cache from disk 1030 - func loadUpdateCheckCache() *UpdateCheckCache { 1031 - path := getUpdateCheckCachePath() 1032 - if path == "" { 1033 - return nil 1034 - } 1035 - 1036 - data, err := os.ReadFile(path) 1037 - if err != nil { 1038 - return nil 1039 - } 1040 - 1041 - var cache UpdateCheckCache 1042 - if err := json.Unmarshal(data, &cache); err != nil { 1043 - return nil 1044 - } 1045 - 1046 - return &cache 1047 - } 1048 - 1049 - // saveUpdateCheckCache saves the update check cache to disk 1050 - func saveUpdateCheckCache(cache *UpdateCheckCache) { 1051 - path := getUpdateCheckCachePath() 1052 - if path == "" { 1053 - return 1054 - } 1055 - 1056 - data, err := json.MarshalIndent(cache, "", " ") 1057 - if err != nil { 1058 - return 1059 - } 1060 - 1061 - // Ensure directory exists 1062 - dir := filepath.Dir(path) 1063 - if err := os.MkdirAll(dir, 0700); err != nil { 1064 - return 1065 - } 1066 - 1067 - if err := os.WriteFile(path, data, 0600); err != nil { 1068 - return // Cache write failed, non-critical 1069 53 } 1070 54 }
+107
cmd/credential-helper/process_darwin.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "unsafe" 8 + 9 + "golang.org/x/sys/unix" 10 + ) 11 + 12 + // getProcessArgs uses kern.procargs2 sysctl to get process arguments. 13 + // This is the same mechanism ps(1) uses on macOS — no exec.Command needed. 14 + // 15 + // The kern.procargs2 buffer layout: 16 + // 17 + // [4 bytes: argc as int32] 18 + // [executable path\0] 19 + // [padding \0 bytes] 20 + // [argv[0]\0][argv[1]\0]...[argv[argc-1]\0] 21 + // [env vars...] 22 + func getProcessArgs(pid int) ([]string, error) { 23 + // kern.procargs2 MIB: CTL_KERN=1, KERN_PROCARGS2=49 24 + mib := []int32{1, 49, int32(pid)} //nolint:mnd 25 + 26 + // First call to get buffer size 27 + n := uintptr(0) 28 + if err := sysctl(mib, nil, &n, nil, 0); err != nil { 29 + return nil, fmt.Errorf("sysctl size query for pid %d: %w", pid, err) 30 + } 31 + 32 + buf := make([]byte, n) 33 + if err := sysctl(mib, &buf[0], &n, nil, 0); err != nil { 34 + return nil, fmt.Errorf("sysctl read for pid %d: %w", pid, err) 35 + } 36 + buf = buf[:n] 37 + 38 + if len(buf) < 4 { 39 + return nil, fmt.Errorf("procargs2 buffer too short for pid %d", pid) 40 + } 41 + 42 + // First 4 bytes: argc 43 + argc := int(binary.LittleEndian.Uint32(buf[:4])) 44 + pos := 4 45 + 46 + // Skip executable path (null-terminated) 47 + end := bytes.IndexByte(buf[pos:], 0) 48 + if end == -1 { 49 + return nil, fmt.Errorf("no null terminator in exec path for pid %d", pid) 50 + } 51 + pos += end + 1 52 + 53 + // Skip padding null bytes 54 + for pos < len(buf) && buf[pos] == 0 { 55 + pos++ 56 + } 57 + 58 + // Read argc arguments 59 + args := make([]string, 0, argc) 60 + for i := 0; i < argc && pos < len(buf); i++ { 61 + end := bytes.IndexByte(buf[pos:], 0) 62 + if end == -1 { 63 + args = append(args, string(buf[pos:])) 64 + break 65 + } 66 + args = append(args, string(buf[pos:pos+end])) 67 + pos += end + 1 68 + } 69 + 70 + if len(args) == 0 { 71 + return nil, fmt.Errorf("no args found for pid %d", pid) 72 + } 73 + 74 + return args, nil 75 + } 76 + 77 + // getParentPID uses kern.proc.pid sysctl to find the parent PID. 78 + func getParentPID(pid int) (int, error) { 79 + // kern.proc.pid MIB: CTL_KERN=1, KERN_PROC=14, KERN_PROC_PID=1 80 + mib := []int32{1, 14, 1, int32(pid)} //nolint:mnd 81 + 82 + var kinfo unix.KinfoProc 83 + n := uintptr(unsafe.Sizeof(kinfo)) 84 + 85 + if err := sysctl(mib, (*byte)(unsafe.Pointer(&kinfo)), &n, nil, 0); err != nil { 86 + return 0, fmt.Errorf("sysctl kern.proc.pid for pid %d: %w", pid, err) 87 + } 88 + 89 + return int(kinfo.Eproc.Ppid), nil 90 + } 91 + 92 + // sysctl is a thin wrapper around unix.Sysctl raw syscall. 93 + func sysctl(mib []int32, old *byte, oldlen *uintptr, new *byte, newlen uintptr) error { 94 + _, _, errno := unix.Syscall6( 95 + unix.SYS___SYSCTL, 96 + uintptr(unsafe.Pointer(&mib[0])), 97 + uintptr(len(mib)), 98 + uintptr(unsafe.Pointer(old)), 99 + uintptr(unsafe.Pointer(oldlen)), 100 + uintptr(unsafe.Pointer(new)), 101 + newlen, 102 + ) 103 + if errno != 0 { 104 + return errno 105 + } 106 + return nil 107 + }
+42
cmd/credential-helper/process_linux.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strconv" 7 + "strings" 8 + ) 9 + 10 + // getProcessArgs reads /proc/<pid>/cmdline to get process arguments. 11 + func getProcessArgs(pid int) ([]string, error) { 12 + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) 13 + if err != nil { 14 + return nil, fmt.Errorf("reading /proc/%d/cmdline: %w", pid, err) 15 + } 16 + 17 + s := strings.TrimRight(string(data), "\x00") 18 + if s == "" { 19 + return nil, fmt.Errorf("empty cmdline for pid %d", pid) 20 + } 21 + 22 + return strings.Split(s, "\x00"), nil 23 + } 24 + 25 + // getParentPID reads /proc/<pid>/status to find the parent PID. 26 + func getParentPID(pid int) (int, error) { 27 + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) 28 + if err != nil { 29 + return 0, err 30 + } 31 + 32 + for _, line := range strings.Split(string(data), "\n") { 33 + if strings.HasPrefix(line, "PPid:") { 34 + fields := strings.Fields(line) 35 + if len(fields) >= 2 { 36 + return strconv.Atoi(fields[1]) 37 + } 38 + } 39 + } 40 + 41 + return 0, fmt.Errorf("PPid not found in /proc/%d/status", pid) 42 + }
+19
cmd/credential-helper/process_other.go
··· 1 + //go:build !linux && !darwin 2 + 3 + package main 4 + 5 + import ( 6 + "fmt" 7 + "runtime" 8 + ) 9 + 10 + // getProcessArgs is not supported on this platform. 11 + // The credential helper falls back to the active account. 12 + func getProcessArgs(pid int) ([]string, error) { 13 + return nil, fmt.Errorf("process introspection not supported on %s", runtime.GOOS) 14 + } 15 + 16 + // getParentPID is not supported on this platform. 17 + func getParentPID(pid int) (int, error) { 18 + return 0, fmt.Errorf("process introspection not supported on %s", runtime.GOOS) 19 + }
+234
cmd/credential-helper/protocol.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "strings" 8 + 9 + "github.com/spf13/cobra" 10 + ) 11 + 12 + // Credentials represents docker credentials (Docker credential helper protocol) 13 + type Credentials struct { 14 + ServerURL string `json:"ServerURL,omitempty"` 15 + Username string `json:"Username,omitempty"` 16 + Secret string `json:"Secret,omitempty"` 17 + } 18 + 19 + func newGetCmd() *cobra.Command { 20 + return &cobra.Command{ 21 + Use: "get", 22 + Short: "Get credentials for a registry (Docker protocol)", 23 + Hidden: true, 24 + RunE: runGet, 25 + } 26 + } 27 + 28 + func newStoreCmd() *cobra.Command { 29 + return &cobra.Command{ 30 + Use: "store", 31 + Short: "Store credentials (Docker protocol)", 32 + Hidden: true, 33 + RunE: runStore, 34 + } 35 + } 36 + 37 + func newEraseCmd() *cobra.Command { 38 + return &cobra.Command{ 39 + Use: "erase", 40 + Short: "Erase credentials (Docker protocol)", 41 + Hidden: true, 42 + RunE: runErase, 43 + } 44 + } 45 + 46 + func newListCmd() *cobra.Command { 47 + return &cobra.Command{ 48 + Use: "list", 49 + Short: "List all credentials (Docker protocol extension)", 50 + Hidden: true, 51 + RunE: runList, 52 + } 53 + } 54 + 55 + func runGet(cmd *cobra.Command, args []string) error { 56 + // If stdin is a terminal, the user ran this directly (not Docker calling us) 57 + if isTerminal(os.Stdin) { 58 + fmt.Fprintf(os.Stderr, "The 'get' command is part of the Docker credential helper protocol.\n") 59 + fmt.Fprintf(os.Stderr, "It should not be run directly.\n\n") 60 + fmt.Fprintf(os.Stderr, "To authenticate with a registry, run:\n") 61 + fmt.Fprintf(os.Stderr, " docker-credential-atcr login\n\n") 62 + fmt.Fprintf(os.Stderr, "To check your accounts:\n") 63 + fmt.Fprintf(os.Stderr, " docker-credential-atcr status\n") 64 + return fmt.Errorf("not a pipe") 65 + } 66 + 67 + // Docker sends the server URL as a plain string on stdin (not JSON) 68 + var serverURL string 69 + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 70 + return fmt.Errorf("reading server URL: %w", err) 71 + } 72 + 73 + appViewURL := buildAppViewURL(serverURL) 74 + 75 + cfg, err := loadConfig() 76 + if err != nil { 77 + fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) 78 + } 79 + 80 + acct, err := cfg.resolveAccount(appViewURL, serverURL) 81 + if err != nil { 82 + return err 83 + } 84 + 85 + // Validate credentials 86 + result := validateCredentials(appViewURL, acct.Handle, acct.DeviceSecret) 87 + if !result.Valid { 88 + if result.OAuthSessionExpired { 89 + loginURL := result.LoginURL 90 + if loginURL == "" { 91 + loginURL = appViewURL + "/auth/oauth/login" 92 + } 93 + fmt.Fprintf(os.Stderr, "OAuth session expired for %s.\n", acct.Handle) 94 + fmt.Fprintf(os.Stderr, "Please visit: %s\n", loginURL) 95 + fmt.Fprintf(os.Stderr, "Then retry your docker command.\n") 96 + return fmt.Errorf("oauth session expired") 97 + } 98 + 99 + // Generic auth failure — remove the bad account 100 + fmt.Fprintf(os.Stderr, "Credentials for %s are invalid.\n", acct.Handle) 101 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n") 102 + cfg.removeAccount(appViewURL, acct.Handle) 103 + cfg.save() //nolint:errcheck 104 + return fmt.Errorf("invalid credentials") 105 + } 106 + 107 + // Check for updates (cached, non-blocking) 108 + checkAndNotifyUpdate(appViewURL) 109 + 110 + // Return credentials for Docker 111 + creds := Credentials{ 112 + ServerURL: serverURL, 113 + Username: acct.Handle, 114 + Secret: acct.DeviceSecret, 115 + } 116 + 117 + return json.NewEncoder(os.Stdout).Encode(creds) 118 + } 119 + 120 + func runStore(cmd *cobra.Command, args []string) error { 121 + var creds Credentials 122 + if err := json.NewDecoder(os.Stdin).Decode(&creds); err != nil { 123 + return fmt.Errorf("decoding credentials: %w", err) 124 + } 125 + 126 + // Only store if the secret looks like a device secret 127 + if !strings.HasPrefix(creds.Secret, "atcr_device_") { 128 + // Not our device secret — ignore (e.g., docker login with app-password) 129 + return nil 130 + } 131 + 132 + appViewURL := buildAppViewURL(creds.ServerURL) 133 + 134 + cfg, err := loadConfig() 135 + if err != nil { 136 + fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err) 137 + } 138 + 139 + cfg.addAccount(appViewURL, &Account{ 140 + Handle: creds.Username, 141 + DeviceSecret: creds.Secret, 142 + }) 143 + 144 + return cfg.save() 145 + } 146 + 147 + func runErase(cmd *cobra.Command, args []string) error { 148 + var serverURL string 149 + if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil { 150 + return fmt.Errorf("reading server URL: %w", err) 151 + } 152 + 153 + appViewURL := buildAppViewURL(serverURL) 154 + 155 + cfg, err := loadConfig() 156 + if err != nil { 157 + return nil // No config, nothing to erase 158 + } 159 + 160 + reg := cfg.findRegistry(appViewURL) 161 + if reg == nil { 162 + return nil 163 + } 164 + 165 + // Erase the active account (or sole account) 166 + handle := reg.Active 167 + if handle == "" && len(reg.Accounts) == 1 { 168 + for h := range reg.Accounts { 169 + handle = h 170 + } 171 + } 172 + if handle == "" { 173 + return nil 174 + } 175 + 176 + cfg.removeAccount(appViewURL, handle) 177 + return cfg.save() 178 + } 179 + 180 + func runList(cmd *cobra.Command, args []string) error { 181 + cfg, err := loadConfig() 182 + if err != nil { 183 + // Return empty object 184 + fmt.Println("{}") 185 + return nil 186 + } 187 + 188 + // Docker list protocol: {"ServerURL": "Username", ...} 189 + result := make(map[string]string) 190 + for url, reg := range cfg.Registries { 191 + // Strip scheme for Docker compatibility 192 + host := strings.TrimPrefix(url, "https://") 193 + host = strings.TrimPrefix(host, "http://") 194 + for _, acct := range reg.Accounts { 195 + result[host] = acct.Handle 196 + } 197 + } 198 + 199 + return json.NewEncoder(os.Stdout).Encode(result) 200 + } 201 + 202 + // checkAndNotifyUpdate checks for updates in the background and notifies the user 203 + func checkAndNotifyUpdate(appViewURL string) { 204 + cache := loadUpdateCheckCache() 205 + if cache != nil && cache.Current == version { 206 + // Cache is fresh and for current version 207 + if isNewerVersion(cache.Latest, version) { 208 + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, version) 209 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") 210 + } 211 + // Check if cache is still fresh (24h) 212 + if cache.CheckedAt.Add(updateCheckCacheTTL).After(timeNow()) { 213 + return 214 + } 215 + } 216 + 217 + // Fetch version info 218 + apiURL := appViewURL + "/api/credential-helper/version" 219 + versionInfo, err := fetchVersionInfo(apiURL) 220 + if err != nil { 221 + return // Silently fail 222 + } 223 + 224 + saveUpdateCheckCache(&UpdateCheckCache{ 225 + CheckedAt: timeNow(), 226 + Latest: versionInfo.Latest, 227 + Current: version, 228 + }) 229 + 230 + if isNewerVersion(versionInfo.Latest, version) { 231 + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", versionInfo.Latest, version) 232 + fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") 233 + } 234 + }
+1 -1
deploy/upcloud/go.mod
··· 1 1 module atcr.io/deploy 2 2 3 - go 1.25.4 3 + go 1.25.7 4 4 5 5 require ( 6 6 github.com/UpCloudLtd/upcloud-go-api/v8 v8.34.3
+165
docs/CREDENTIAL_HELPER_V2.md
··· 1 + # Credential Helper Rewrite 2 + 3 + ## Context 4 + 5 + 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`). 6 + 7 + ## Approach 8 + 9 + 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. 10 + 11 + **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. 12 + 13 + ## Command Tree 14 + 15 + ``` 16 + docker-credential-atcr 17 + ├── get (Docker protocol — stdin/stdout, hidden, smart account detection) 18 + ├── store (Docker protocol — stdin, hidden) 19 + ├── erase (Docker protocol — stdin, hidden) 20 + ├── list (Docker protocol extension, hidden) 21 + ├── login (Interactive device flow with huh prompts) 22 + ├── logout (Remove account credentials) 23 + ├── status (Show all accounts with active indicators) 24 + ├── switch (Switch active account — auto-toggle for 2, select for 3+) 25 + ├── configure-docker (Auto-edit ~/.docker/config.json credHelpers) 26 + ├── update (Self-update, existing logic preserved) 27 + └── version (Built-in via cobra) 28 + ``` 29 + 30 + ## Smart Account Resolution (`get` command) 31 + 32 + The `get` command resolves which account to use with this priority chain — fully non-interactive: 33 + 34 + ``` 35 + 1. Parse parent process cmdline → extract identity from image ref 36 + docker push atcr.io/evan.jarrett.net/test:latest 37 + → parent cmdline contains "evan.jarrett.net" → use that account 38 + 39 + 2. Fall back to active account (set by `switch` command) 40 + 41 + 3. Fall back to sole account (if only one exists for this registry) 42 + 43 + 4. Error with helpful message: 44 + "Multiple accounts for atcr.io. Run: docker-credential-atcr switch" 45 + ``` 46 + 47 + **Parent process detection** (in `helpers.go`): 48 + - Linux: read `/proc/<ppid>/cmdline` (null-separated args) 49 + - macOS: `ps -o args= -p <ppid>` 50 + - Windows: best-effort via `wmic` or skip (fall to active account) 51 + - Parse image ref: find the arg matching `<registry-host>/<identity>/...`, extract `<identity>` 52 + - Graceful failure: if parent isn't Docker, cmdline unreadable, or image ref not parseable → fall through to active account 53 + 54 + ## File Structure 55 + 56 + ``` 57 + cmd/credential-helper/ 58 + main.go — Cobra root command, version vars, subcommand registration 59 + config.go — Config types, load/save/migrate, getConfigPath 60 + device_auth.go — authorizeDevice(), validateCredentials() HTTP logic 61 + protocol.go — Docker protocol: get, store, erase, list (all hidden) 62 + cmd_login.go — login command (huh prompts + device flow) 63 + cmd_logout.go — logout command (huh confirm) 64 + cmd_status.go — status display 65 + cmd_switch.go — switch command (huh select) 66 + cmd_configure.go — configure-docker (edit ~/.docker/config.json) 67 + cmd_update.go — update command (moved from existing code) 68 + helpers.go — openBrowser, buildAppViewURL, isInsecureRegistry, parentCmdline, etc. 69 + ``` 70 + 71 + ## Config Format (`~/.atcr/device.json`) 72 + 73 + ```json 74 + { 75 + "version": 2, 76 + "registries": { 77 + "https://atcr.io": { 78 + "active": "evan.jarrett.net", 79 + "accounts": { 80 + "evan.jarrett.net": { 81 + "handle": "evan.jarrett.net", 82 + "did": "did:plc:abc123", 83 + "device_secret": "atcr_device_..." 84 + }, 85 + "michelle.jarrett.net": { 86 + "handle": "michelle.jarrett.net", 87 + "did": "did:plc:def456", 88 + "device_secret": "atcr_device_..." 89 + } 90 + } 91 + }, 92 + "https://buoy.cr": { 93 + "active": "evan.jarrett.net", 94 + "accounts": { ... } 95 + } 96 + } 97 + } 98 + ``` 99 + 100 + **Migration**: `loadConfig()` auto-detects and migrates from old formats: 101 + - Legacy single-device `{handle, device_secret, appview_url}` → v2 102 + - Current multi-registry `{credentials: {url: {...}}}` → v2 103 + - Writes back migrated config on first load 104 + 105 + ## Key Behavioral Changes 106 + 107 + | Command | Current | New | 108 + |---------|---------|-----| 109 + | `get` | Opens browser, polls 2min if no creds | Smart detection → active account → error | 110 + | `get` (multi-account) | N/A (single account only) | Auto-detects identity from parent cmdline | 111 + | `get` (no stdin) | Hangs forever | Detects terminal, prints help, exits 1 | 112 + | `get` (OAuth expired) | Auto-opens browser, polls | Prints login URL, exits 1 | 113 + | `store` | No-op | Stores if secret is device secret (`atcr_device_*`) | 114 + | `erase` | Removes all creds for host | Removes active account only | 115 + | No args | Prints bare usage | Prints full cobra help with all commands | 116 + 117 + ## Dependencies 118 + 119 + - `github.com/spf13/cobra` — already in go.mod 120 + - `github.com/charmbracelet/huh` — new (pure Go, CGO_ENABLED=0 safe) 121 + 122 + No changes to `.goreleaser.yaml` needed. 123 + 124 + ## Implementation Order 125 + 126 + ### Phase 1: Foundation 127 + 1. `helpers.go` — move utility functions verbatim + add `getParentCmdline()` and `detectIdentityFromParent(registryHost)` 128 + 2. `config.go` — new config types + migration from old formats 129 + 3. `main.go` — Cobra root command, register all subcommands 130 + 131 + ### Phase 2: Docker Protocol (must work for existing users) 132 + 4. `device_auth.go` — extract `authorizeDevice()` + `validateCredentials()` 133 + 5. `protocol.go` — `get`/`store`/`erase`/`list` using new config with smart account resolution 134 + 135 + ### Phase 3: User Commands 136 + 6. `cmd_login.go` — interactive device flow with huh spinner 137 + 7. `cmd_status.go` — display all registries/accounts 138 + 8. `cmd_switch.go` — huh select for account switching 139 + 9. `cmd_logout.go` — huh confirm for removal 140 + 10. `cmd_configure.go` — Docker config.json manipulation 141 + 11. `cmd_update.go` — move existing update logic 142 + 143 + ### Phase 4: Polish 144 + 12. Add `huh` to go.mod 145 + 13. Delete old `main.go` contents (replaced by new files) 146 + 147 + ## What to Keep vs Rewrite 148 + 149 + **Keep** (move to new files): `openBrowser()`, `buildAppViewURL()`, `isInsecureRegistry()`, `getDockerInsecureRegistries()`, `readDockerDaemonConfig()`, `stripPort()`, `isTerminal()`, `authorizeDevice()` HTTP logic, `validateCredentials()`, all update/version check functions. 150 + 151 + **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. 152 + 153 + **New**: `list`, `login`, `logout`, `status`, `switch`, `configure-docker` commands. Config migration. Parent process identity detection. huh integration. 154 + 155 + ## Verification 156 + 157 + 1. Build: `go build -o bin/docker-credential-atcr ./cmd/credential-helper` 158 + 2. Help works: `bin/docker-credential-atcr --help` shows all user commands 159 + 3. Protocol works: `echo "atcr.io" | bin/docker-credential-atcr get` returns credentials or helpful error 160 + 4. No hang: `bin/docker-credential-atcr get` (no stdin pipe) detects terminal, prints help, exits 161 + 5. Smart detection: `docker push atcr.io/evan.jarrett.net/test:latest` auto-selects `evan.jarrett.net` 162 + 6. Login flow: `bin/docker-credential-atcr login` triggers device auth with huh prompts 163 + 7. Status: `bin/docker-credential-atcr status` shows configured accounts 164 + 8. Config migration: Place old-format `~/.atcr/device.json`, run any command, verify auto-migration 165 + 9. GoReleaser: `CGO_ENABLED=0 go build ./cmd/credential-helper` succeeds
+2 -2
docs/DEVELOPMENT.md
··· 47 47 │ (changes appear instantly in container) 48 48 49 49 ┌─────────────────────────────────────────────────────┐ 50 - │ Container (golang:1.25.2 base, has all tools) │ 50 + │ Container (golang:1.25.7 base, has all tools) │ 51 51 │ │ 52 52 │ ┌──────────────────────────────────────┐ │ 53 53 │ │ Air (hot reload tool) │ │ ··· 107 107 108 108 ```dockerfile 109 109 # Development Dockerfile with hot reload support 110 - FROM golang:1.25.2-trixie 110 + FROM golang:1.25.7-trixie 111 111 112 112 # Install Air for hot reload 113 113 RUN go install github.com/cosmtrek/air@latest
+24 -1
go.mod
··· 9 9 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 10 10 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 11 11 github.com/bluesky-social/indigo v0.0.0-20260213003059-85cdd0d6871c 12 + github.com/charmbracelet/huh v0.8.0 13 + github.com/charmbracelet/huh/spinner v0.0.0-20260216111231-bffc99a26329 14 + github.com/charmbracelet/lipgloss v1.1.0 12 15 github.com/did-method-plc/go-didplc v0.0.0-20251009212921-7b7a252b8019 13 16 github.com/distribution/distribution/v3 v3.0.0 14 17 github.com/distribution/reference v0.6.0 ··· 45 48 go.yaml.in/yaml/v4 v4.0.0-rc.4 46 49 golang.org/x/crypto v0.48.0 47 50 golang.org/x/image v0.36.0 51 + golang.org/x/sys v0.41.0 48 52 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 53 gorm.io/gorm v1.31.1 50 54 ) ··· 54 58 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 55 59 github.com/ajg/form v1.6.1 // indirect 56 60 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 61 + github.com/atotto/clipboard v0.1.4 // indirect 57 62 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 58 63 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect 59 64 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect ··· 69 74 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect 70 75 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect 71 76 github.com/aws/smithy-go v1.24.0 // indirect 77 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 72 78 github.com/aymerick/douceur v0.2.0 // indirect 73 79 github.com/beorn7/perks v1.0.1 // indirect 74 80 github.com/bshuster-repo/logrus-logstash-hook v1.1.0 // indirect 81 + github.com/catppuccin/go v0.3.0 // indirect 75 82 github.com/cenkalti/backoff/v5 v5.0.3 // indirect 76 83 github.com/cespare/xxhash/v2 v2.3.0 // indirect 84 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect 85 + github.com/charmbracelet/bubbletea v1.3.10 // indirect 86 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 87 + github.com/charmbracelet/x/ansi v0.10.1 // indirect 88 + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 89 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 90 + github.com/charmbracelet/x/term v0.2.1 // indirect 77 91 github.com/coreos/go-systemd/v22 v22.7.0 // indirect 78 92 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 79 93 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 80 94 github.com/docker/docker-credential-helpers v0.9.5 // indirect 81 95 github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect 82 96 github.com/docker/go-metrics v0.0.1 // indirect 97 + github.com/dustin/go-humanize v1.0.1 // indirect 98 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 83 99 github.com/fatih/color v1.18.0 // indirect 84 100 github.com/felixge/httpsnoop v1.0.4 // indirect 85 101 github.com/fsnotify/fsnotify v1.9.0 // indirect ··· 118 134 github.com/jmespath/go-jmespath v0.4.0 // indirect 119 135 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 120 136 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c // indirect 137 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 121 138 github.com/mattn/go-colorable v0.1.14 // indirect 122 139 github.com/mattn/go-isatty v0.0.20 // indirect 140 + github.com/mattn/go-localereader v0.0.1 // indirect 141 + github.com/mattn/go-runewidth v0.0.16 // indirect 123 142 github.com/minio/sha256-simd v1.0.1 // indirect 143 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 124 144 github.com/mr-tron/base58 v1.2.0 // indirect 145 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 146 + github.com/muesli/cancelreader v0.2.2 // indirect 147 + github.com/muesli/termenv v0.16.0 // indirect 125 148 github.com/multiformats/go-base32 v0.1.0 // indirect 126 149 github.com/multiformats/go-base36 v0.2.0 // indirect 127 150 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 149 172 github.com/spf13/cast v1.10.0 // indirect 150 173 github.com/spf13/pflag v1.0.10 // indirect 151 174 github.com/subosito/gotenv v1.6.0 // indirect 175 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 152 176 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 153 177 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 154 178 go.opentelemetry.io/auto/sdk v1.2.1 // indirect ··· 181 205 golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect 182 206 golang.org/x/net v0.50.0 // indirect 183 207 golang.org/x/sync v0.19.0 // indirect 184 - golang.org/x/sys v0.41.0 // indirect 185 208 golang.org/x/text v0.34.0 // indirect 186 209 golang.org/x/time v0.14.0 // indirect 187 210 google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
+64
go.sum
··· 1 1 github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= 2 2 github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 3 3 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 + github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 5 + github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 4 6 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 5 7 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 6 8 github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= ··· 10 12 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 11 13 github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 12 14 github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 15 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 16 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 17 github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= 14 18 github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= 15 19 github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= ··· 50 54 github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= 51 55 github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 52 56 github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 57 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 58 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 59 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 60 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 53 61 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 54 62 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 55 63 github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= ··· 66 74 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 67 75 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 68 76 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 77 + github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 78 + github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 69 79 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= 70 80 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 71 81 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 72 82 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 83 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 84 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 85 + github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 86 + github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 87 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 88 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 89 + github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= 90 + github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 91 + github.com/charmbracelet/huh/spinner v0.0.0-20260216111231-bffc99a26329 h1:0qvbszNGxDNsLfktnN6eFngemvxTzKWyL2ER1AEmawM= 92 + github.com/charmbracelet/huh/spinner v0.0.0-20260216111231-bffc99a26329/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg= 93 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 94 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 95 + github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 96 + github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 97 + github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 98 + github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 99 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 100 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 101 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 102 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 103 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 104 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 105 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 106 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 107 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 108 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 109 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 110 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 111 + github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 112 + github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 73 113 github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= 74 114 github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= 75 115 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 76 116 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 117 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 118 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 77 119 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 78 120 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 79 121 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 96 138 github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= 97 139 github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= 98 140 github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 141 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 142 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 99 143 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 100 144 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 145 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 146 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 101 147 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 102 148 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 103 149 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 296 342 github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= 297 343 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c h1:WsJ6G+hkDXIMfQE8FIxnnziT26WmsRgZhdWQ0IQGlcc= 298 344 github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c/go.mod h1:gIcFddvsvPcRCO6QDmWH9/zcFd5U26QWWRMgZh4ddyo= 345 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 346 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 299 347 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 300 348 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 301 349 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 302 350 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 351 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 352 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 353 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 354 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 303 355 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 304 356 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 305 357 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= ··· 307 359 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= 308 360 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 309 361 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 362 + github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 363 + github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 310 364 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 311 365 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 312 366 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 313 367 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 314 368 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 315 369 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 370 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 371 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 372 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 373 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 374 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 375 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 316 376 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 317 377 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 318 378 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 381 441 github.com/redis/go-redis/extra/redisotel/v9 v9.17.3/go.mod h1:gR39sPK/dJZlqgIA9Nm4JFHcQJPyhsISBLj708nrD4w= 382 442 github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= 383 443 github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= 444 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 384 445 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 385 446 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 386 447 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 440 501 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 441 502 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 442 503 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 504 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 505 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 443 506 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 444 507 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 445 508 github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= ··· 556 619 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 557 620 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 558 621 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 622 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 559 623 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 560 624 golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= 561 625 golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+1
go.work.sum
··· 538 538 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 539 539 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 540 540 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 541 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 541 542 golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= 542 543 golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 543 544 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+1 -1
pkg/appview/handlers/repository.go
··· 294 294 IsStarred bool 295 295 IsOwner bool // Whether current user owns this repository 296 296 ReadmeHTML template.HTML 297 - ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 297 + ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 298 298 ScanBatchParams []template.HTML // Pre-encoded query strings for batch scan-result endpoint (one per hold) 299 299 }{ 300 300 PageData: NewPageData(r, &h.BaseUIHandler),
+43 -4
pkg/appview/handlers/scan_result.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "database/sql" 6 7 "encoding/json" 7 8 "fmt" 8 9 "html/template" ··· 13 14 "sync" 14 15 "time" 15 16 17 + "atcr.io/pkg/appview/db" 16 18 "atcr.io/pkg/atproto" 17 19 ) 18 20 ··· 54 56 return 55 57 } 56 58 57 - // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 58 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 59 + // Check if this hold has a successor — scan records may live there instead 60 + resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID) 61 + 62 + // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID; 63 + // otherwise use the original holdEndpoint (which may already be a URL). 64 + holdURLTarget := holdEndpoint 65 + if resolvedHoldDID != holdDID { 66 + holdDID = resolvedHoldDID 67 + holdURLTarget = resolvedHoldDID 68 + } 69 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget) 59 70 if err != nil { 60 71 slog.Debug("Failed to resolve hold URL", "holdEndpoint", holdEndpoint, "error", err) 61 72 h.renderBadge(w, vulnBadgeData{Error: true}) ··· 219 230 return 220 231 } 221 232 222 - // Resolve to HTTP endpoint URL (handles DID, URL, or hostname) 223 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdEndpoint) 233 + // Check if this hold has a successor — scan records may live there instead 234 + resolvedHoldDID := resolveHoldSuccessor(h.ReadOnlyDB, holdDID) 235 + 236 + // Resolve to HTTP endpoint URL. If successor redirected, resolve the new DID; 237 + // otherwise use the original holdEndpoint (which may already be a URL). 238 + holdURLTarget := holdEndpoint 239 + if resolvedHoldDID != holdDID { 240 + holdDID = resolvedHoldDID 241 + holdURLTarget = resolvedHoldDID 242 + } 243 + holdURL, err := atproto.ResolveHoldURL(r.Context(), holdURLTarget) 224 244 if err != nil { 225 245 slog.Debug("Failed to resolve hold URL for batch scan", "holdEndpoint", holdEndpoint, "error", err) 226 246 w.Header().Set("Content-Type", "text/html") ··· 266 286 template.HTMLEscapeString(res.hexDigest), buf.String()) 267 287 } 268 288 } 289 + 290 + // resolveHoldSuccessor checks if a hold has a successor in the cached captain records. 291 + // Returns the successor DID if set, otherwise returns the original holdDID. 292 + // Single-hop only — does not follow chains. 293 + func resolveHoldSuccessor(database *sql.DB, holdDID string) string { 294 + if database == nil { 295 + return holdDID 296 + } 297 + captain, err := db.GetCaptainRecord(database, holdDID) 298 + if err != nil || captain == nil { 299 + return holdDID 300 + } 301 + if captain.Successor != "" { 302 + slog.Debug("Scan result: following hold successor", 303 + "from", holdDID, "to", captain.Successor) 304 + return captain.Successor 305 + } 306 + return holdDID 307 + }
+26 -10
pkg/appview/handlers/settings.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "html/template" 6 7 "log/slog" ··· 13 14 "atcr.io/pkg/appview/middleware" 14 15 "atcr.io/pkg/appview/storage" 15 16 "atcr.io/pkg/atproto" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 16 18 ) 17 19 18 20 // HoldDisplay represents a hold for display in the UI ··· 70 72 for _, hold := range availableHolds { 71 73 display := HoldDisplay{ 72 74 DID: hold.HoldDID, 73 - DisplayName: deriveDisplayName(hold.HoldDID), 75 + DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID), 74 76 Region: hold.Region, 75 77 Membership: hold.Membership, 76 78 } ··· 106 108 showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered 107 109 108 110 // Look up AppView default hold details from database 109 - appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID) 111 + appViewDefaultDisplay := resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, h.DefaultHoldDID) 110 112 var appViewDefaultRegion string 111 113 if h.DefaultHoldDID != "" && h.DB != nil { 112 114 if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil { ··· 143 145 PageData: NewPageData(r, &h.BaseUIHandler), 144 146 Meta: meta, 145 147 CurrentHoldDID: profile.DefaultHold, 146 - CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold), 148 + CurrentHoldDisplay: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, profile.DefaultHold), 147 149 ShowCurrentHold: showCurrentHold, 148 150 AppViewDefaultHoldDID: h.DefaultHoldDID, 149 151 AppViewDefaultHoldDisplay: appViewDefaultDisplay, ··· 165 167 } 166 168 } 167 169 168 - // deriveDisplayName derives a human-readable name from a hold DID 169 - func deriveDisplayName(did string) string { 170 - // For did:web, extract the domain 170 + // resolveHoldDisplayName resolves a hold DID to a human-readable handle via the 171 + // identity directory. Falls back to domain extraction (did:web) or truncation (did:plc). 172 + func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string { 173 + if did == "" { 174 + return "" 175 + } 176 + 177 + // Try resolving via identity directory 178 + if h.Directory != nil { 179 + parsed, err := syntax.ParseDID(did) 180 + if err == nil { 181 + ident, err := h.Directory.LookupDID(ctx, parsed) 182 + if err == nil && ident.Handle.String() != "handle.invalid" && ident.Handle.String() != "" { 183 + return ident.Handle.String() 184 + } 185 + } 186 + } 187 + 188 + // Fallback: extract domain from did:web 171 189 if strings.HasPrefix(did, "did:web:") { 172 190 domain := strings.TrimPrefix(did, "did:web:") 173 - // URL-decode the domain (did:web encodes : as %3A) 174 - decoded, err := url.QueryUnescape(domain) 175 - if err == nil { 191 + if decoded, err := url.QueryUnescape(domain); err == nil { 176 192 return decoded 177 193 } 178 194 return domain 179 195 } 180 196 181 - // For did:plc, truncate for display 197 + // Fallback: truncate did:plc 182 198 if len(did) > 24 { 183 199 return did[:24] + "..." 184 200 }
+1 -1
pkg/appview/handlers/subscription.go
··· 113 113 } 114 114 115 115 // Set hold display name so users know which hold the subscription applies to 116 - info.HoldDisplayName = deriveDisplayName(holdDID) 116 + info.HoldDisplayName = resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, holdDID) 117 117 118 118 // Format prices for display 119 119 // Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
-1
pkg/appview/holdhealth/checker_test.go
··· 267 267 t.Errorf("Expected startupDelay=%v, got %v", startupDelay, workerWithDelay.startupDelay) 268 268 } 269 269 } 270 -
-1
pkg/appview/server.go
··· 599 599 return nil 600 600 } 601 601 602 - 603 602 // DomainRoutingMiddleware enforces three-tier domain routing: 604 603 // 605 604 // 1. UI domain (BaseURL hostname): serves web UI, auth, and static assets.
-1
pkg/atproto/lexicon_test.go
··· 653 653 } 654 654 } 655 655 656 - 657 656 func TestIsDID(t *testing.T) { 658 657 tests := []struct { 659 658 name string
+1 -1
pkg/hold/admin/handlers_settings.go
··· 88 88 89 89 // Validate successor DID format if provided 90 90 if successor != "" { 91 - if !atproto.IsDID(successor) || !(strings.HasPrefix(successor, "did:web:") || strings.HasPrefix(successor, "did:plc:")) { 91 + if !atproto.IsDID(successor) || (!strings.HasPrefix(successor, "did:web:") && !strings.HasPrefix(successor, "did:plc:")) { 92 92 setFlash(w, r, "error", "Successor must be a valid did:web: or did:plc: DID") 93 93 http.Redirect(w, r, "/admin#settings", http.StatusFound) 94 94 return
+107 -34
pkg/hold/pds/scan_broadcaster.go
··· 38 38 ownsDB bool // true when this broadcaster opened the connection itself 39 39 40 40 // Proactive scan scheduling 41 - rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled) 42 - stopCh chan struct{} // Signal to stop background goroutines 43 - wg sync.WaitGroup // Wait for background goroutines to finish 44 - userIdx int // Round-robin index through users for proactive scanning 45 - predecessorCache map[string]bool // holdDID → "is this hold's successor us?" 41 + rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled) 42 + stopCh chan struct{} // Signal to stop background goroutines 43 + wg sync.WaitGroup // Wait for background goroutines to finish 44 + userIdx int // Round-robin index through DIDs for proactive scanning 45 + predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?" 46 + 47 + // Relay-based manifest DID discovery 48 + relayEndpoint string // Relay URL for listReposByCollection 49 + manifestDIDs []string // Cached list of DIDs with manifest records 50 + manifestDIDsMu sync.RWMutex // Protects manifestDIDs 46 51 } 47 52 48 53 // ScanSubscriber represents a connected scanner WebSocket client ··· 90 95 91 96 // NewScanBroadcaster creates a new scan job broadcaster 92 97 // dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3") 93 - func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) { 98 + func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) { 94 99 dsn := dbPath 95 100 if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { 96 101 dsn = "file:" + dbPath ··· 116 121 return nil, fmt.Errorf("failed to set busy_timeout: %w", err) 117 122 } 118 123 124 + if relayEndpoint == "" { 125 + relayEndpoint = "https://relay1.us-east.bsky.network" 126 + } 127 + 119 128 sb := &ScanBroadcaster{ 120 129 subscribers: make([]*ScanSubscriber, 0), 121 130 db: db, ··· 129 138 rescanInterval: rescanInterval, 130 139 stopCh: make(chan struct{}), 131 140 predecessorCache: make(map[string]bool), 141 + relayEndpoint: relayEndpoint, 132 142 } 133 143 134 144 if err := sb.initSchema(); err != nil { ··· 142 152 143 153 // Start proactive scan loop if rescan interval is configured 144 154 if rescanInterval > 0 { 145 - sb.wg.Add(1) 155 + sb.wg.Add(2) 146 156 go sb.proactiveScanLoop() 147 - slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval) 157 + go sb.refreshManifestDIDsLoop() 158 + slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint) 148 159 } 149 160 150 161 return sb, nil ··· 152 163 153 164 // NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection. 154 165 // The caller is responsible for the DB lifecycle. 155 - func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) { 166 + func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) { 167 + if relayEndpoint == "" { 168 + relayEndpoint = "https://relay1.us-east.bsky.network" 169 + } 170 + 156 171 sb := &ScanBroadcaster{ 157 172 subscribers: make([]*ScanSubscriber, 0), 158 173 db: db, ··· 166 181 rescanInterval: rescanInterval, 167 182 stopCh: make(chan struct{}), 168 183 predecessorCache: make(map[string]bool), 184 + relayEndpoint: relayEndpoint, 169 185 } 170 186 171 187 if err := sb.initSchema(); err != nil { ··· 176 192 go sb.reDispatchLoop() 177 193 178 194 if rescanInterval > 0 { 179 - sb.wg.Add(1) 195 + sb.wg.Add(2) 180 196 go sb.proactiveScanLoop() 181 - slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval) 197 + go sb.refreshManifestDIDsLoop() 198 + slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint) 182 199 } 183 200 184 201 return sb, nil ··· 709 726 return sb.secret != "" && secret == sb.secret 710 727 } 711 728 729 + // refreshManifestDIDsLoop periodically queries the relay to discover all DIDs 730 + // with io.atcr.manifest records. The cached list is used by the proactive scan loop. 731 + func (sb *ScanBroadcaster) refreshManifestDIDsLoop() { 732 + defer sb.wg.Done() 733 + 734 + // Wait for the system to settle before first refresh 735 + select { 736 + case <-sb.stopCh: 737 + return 738 + case <-time.After(30 * time.Second): 739 + } 740 + 741 + // Initial refresh 742 + sb.refreshManifestDIDs() 743 + 744 + ticker := time.NewTicker(30 * time.Minute) 745 + defer ticker.Stop() 746 + 747 + for { 748 + select { 749 + case <-sb.stopCh: 750 + slog.Info("Manifest DID refresh loop stopped") 751 + return 752 + case <-ticker.C: 753 + sb.refreshManifestDIDs() 754 + } 755 + } 756 + } 757 + 758 + // refreshManifestDIDs queries the relay for all DIDs that have io.atcr.manifest records. 759 + // On success, atomically replaces the cached DID list. On failure, retains the previous list. 760 + func (sb *ScanBroadcaster) refreshManifestDIDs() { 761 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 762 + defer cancel() 763 + 764 + client := atproto.NewClient(sb.relayEndpoint, "", "") 765 + 766 + var allDIDs []string 767 + var cursor string 768 + 769 + for { 770 + result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor) 771 + if err != nil { 772 + slog.Warn("Proactive scan: failed to list repos from relay", 773 + "relay", sb.relayEndpoint, "error", err) 774 + return // Keep existing cached list 775 + } 776 + 777 + for _, repo := range result.Repos { 778 + allDIDs = append(allDIDs, repo.DID) 779 + } 780 + 781 + if result.Cursor == "" || len(result.Repos) == 0 { 782 + break 783 + } 784 + cursor = result.Cursor 785 + } 786 + 787 + sb.manifestDIDsMu.Lock() 788 + sb.manifestDIDs = allDIDs 789 + sb.manifestDIDsMu.Unlock() 790 + 791 + slog.Info("Proactive scan: refreshed manifest DID list from relay", 792 + "count", len(allDIDs), "relay", sb.relayEndpoint) 793 + } 794 + 712 795 // proactiveScanLoop periodically finds manifests needing scanning and enqueues jobs. 713 796 // It fetches manifest records from users' PDS (the source of truth) and creates scan 714 797 // jobs for manifests that haven't been scanned recently. ··· 738 821 739 822 // tryEnqueueProactiveScan finds the next manifest needing a scan and enqueues it. 740 823 // Only enqueues one job per call to avoid flooding the scanner. 824 + // Uses the cached DID list from the relay (refreshed by refreshManifestDIDsLoop). 741 825 func (sb *ScanBroadcaster) tryEnqueueProactiveScan() { 742 826 if !sb.hasConnectedScanners() { 743 827 return ··· 749 833 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 750 834 defer cancel() 751 835 752 - // Get all users who have pushed to this hold 753 - stats, err := sb.pds.ListStats(ctx) 754 - if err != nil { 755 - slog.Error("Proactive scan: failed to list stats", "error", err) 756 - return 757 - } 758 - 759 - // Extract unique user DIDs 760 - seen := make(map[string]bool) 761 - var userDIDs []string 762 - for _, s := range stats { 763 - if !seen[s.OwnerDID] { 764 - seen[s.OwnerDID] = true 765 - userDIDs = append(userDIDs, s.OwnerDID) 766 - } 767 - } 836 + // Read cached DID list from relay discovery 837 + sb.manifestDIDsMu.RLock() 838 + userDIDs := sb.manifestDIDs 839 + sb.manifestDIDsMu.RUnlock() 768 840 769 841 if len(userDIDs) == 0 { 770 842 return 771 843 } 772 844 773 - // Round-robin through users, trying each until we find work or exhaust the list 845 + // Round-robin through DIDs, trying each until we find work or exhaust the list 774 846 for attempts := 0; attempts < len(userDIDs); attempts++ { 775 847 idx := sb.userIdx % len(userDIDs) 776 848 sb.userIdx++ ··· 870 942 return false 871 943 } 872 944 873 - // isOurManifest checks if a manifest's holdDID matches this hold, either directly 874 - // or via successor (the manifest's hold has set us as its successor). 945 + // isOurManifest checks if a manifest's holdDID matches this hold directly, 946 + // or if the manifest's hold has been migrated (has a successor label set). 875 947 func (sb *ScanBroadcaster) isOurManifest(ctx context.Context, holdDID string) bool { 876 948 if holdDID == "" { 877 949 return false ··· 893 965 return isPredecessor 894 966 } 895 967 896 - // checkPredecessor fetches a hold's captain record to check if its successor is us. 968 + // checkPredecessor fetches a hold's captain record to check if it has a successor label 969 + // (meaning the hold has been migrated/retired and its manifests should be scanned by us). 897 970 func (sb *ScanBroadcaster) checkPredecessor(ctx context.Context, holdDID string) bool { 898 971 fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 899 972 defer cancel() ··· 946 1019 return false 947 1020 } 948 1021 949 - if captain.Successor == sb.holdDID { 950 - slog.Info("Proactive scan: discovered predecessor hold", 951 - "predecessorDID", holdDID, "successor", sb.holdDID) 1022 + if captain.Successor != "" { 1023 + slog.Info("Proactive scan: discovered migrated hold (has successor label)", 1024 + "holdDID", holdDID, "successor", captain.Successor) 952 1025 return true 953 1026 } 954 1027
+2 -2
pkg/hold/server.go
··· 196 196 rescanInterval := cfg.Scanner.RescanInterval 197 197 var sb *pds.ScanBroadcaster 198 198 if s.holdDB != nil { 199 - sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS, rescanInterval) 199 + sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoint, s.holdDB.DB, s3Service, s.PDS, rescanInterval) 200 200 } else { 201 201 scanDBPath := cfg.Database.Path + "/db.sqlite3" 202 - sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, s3Service, s.PDS, rescanInterval) 202 + sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoint, scanDBPath, s3Service, s.PDS, rescanInterval) 203 203 } 204 204 if err != nil { 205 205 return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err)
+1 -1
scanner/go.mod
··· 181 181 github.com/json-iterator/go v1.1.12 // indirect 182 182 github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect 183 183 github.com/kevinburke/ssh_config v1.4.0 // indirect 184 - github.com/klauspost/compress v1.18.4 // indirect 184 + github.com/klauspost/compress v1.18.4 185 185 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 186 186 github.com/klauspost/pgzip v1.2.6 // indirect 187 187 github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
+2 -2
scanner/go.sum
··· 252 252 github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 253 253 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 254 254 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 255 - github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 256 - github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 255 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 256 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 257 257 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 258 258 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 259 259 github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
+46 -13
scanner/internal/scan/extractor.go
··· 13 13 14 14 scanner "atcr.io/scanner" 15 15 "atcr.io/scanner/internal/client" 16 + "github.com/klauspost/compress/zstd" 16 17 ) 17 18 18 19 // extractLayers downloads and extracts all image layers via presigned URLs ··· 69 70 slog.Warn("Skipping layer with empty digest", "index", i) 70 71 continue 71 72 } 72 - slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size) 73 + // Skip non-tar layers (cosign signatures, attestations, etc.) 74 + if layer.MediaType != "" && !strings.Contains(layer.MediaType, "tar") { 75 + slog.Info("Skipping non-tar layer", "index", i, "digest", layer.Digest, "mediaType", layer.MediaType) 76 + continue 77 + } 78 + slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size, "mediaType", layer.MediaType) 73 79 74 - layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i)) 80 + layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d", i)) 75 81 if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, layer.Digest, layerPath, secret); err != nil { 76 82 cleanup() 77 83 return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err) 78 84 } 79 85 80 - if err := extractTarGz(layerPath, rootfsDir); err != nil { 86 + if err := extractLayer(layerPath, rootfsDir, layer.MediaType); err != nil { 81 87 cleanup() 82 88 return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err) 83 89 } 84 90 85 - // Remove layer tar.gz to save space 91 + // Remove layer file to save space 86 92 os.Remove(layerPath) 87 93 } 88 94 ··· 107 113 return client.DownloadBlob(presignedURL, destPath) 108 114 } 109 115 110 - // extractTarGz extracts a tar.gz file to a destination directory (overlayfs style) 111 - func extractTarGz(tarGzPath, destDir string) error { 112 - file, err := os.Open(tarGzPath) 116 + // extractLayer extracts a layer tar archive to a destination directory (overlayfs style). 117 + // Supports gzip, zstd, and uncompressed tar based on the OCI media type. 118 + // Falls back to header sniffing if the media type is unrecognized. 119 + func extractLayer(layerPath, destDir, mediaType string) error { 120 + file, err := os.Open(layerPath) 113 121 if err != nil { 114 - return fmt.Errorf("failed to open tar.gz: %w", err) 122 + return fmt.Errorf("failed to open layer: %w", err) 115 123 } 116 124 defer file.Close() 117 125 118 - gzr, err := gzip.NewReader(file) 119 - if err != nil { 120 - return fmt.Errorf("failed to create gzip reader: %w", err) 126 + var tarReader io.Reader 127 + 128 + switch { 129 + case strings.Contains(mediaType, "zstd"): 130 + decoder, err := zstd.NewReader(file) 131 + if err != nil { 132 + return fmt.Errorf("failed to create zstd reader: %w", err) 133 + } 134 + defer decoder.Close() 135 + tarReader = decoder 136 + 137 + case strings.Contains(mediaType, "gzip") || mediaType == "": 138 + // Default to gzip for unspecified media types (most common) 139 + gzr, err := gzip.NewReader(file) 140 + if err != nil { 141 + // If gzip fails, try plain tar (header sniff fallback) 142 + if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { 143 + return fmt.Errorf("failed to create gzip reader: %w", err) 144 + } 145 + slog.Debug("Gzip header invalid, falling back to plain tar", "mediaType", mediaType) 146 + tarReader = file 147 + } else { 148 + defer gzr.Close() 149 + tarReader = gzr 150 + } 151 + 152 + default: 153 + // Uncompressed tar or unknown — try plain tar 154 + tarReader = file 121 155 } 122 - defer gzr.Close() 123 156 124 - tr := tar.NewReader(gzr) 157 + tr := tar.NewReader(tarReader) 125 158 126 159 for { 127 160 header, err := tr.Next()
+173 -62
scripts/update-homebrew-formula.sh
··· 1 1 #!/usr/bin/env bash 2 2 # 3 - # update-homebrew-formula.sh - Helper script to update Homebrew formula with new release 3 + # update-homebrew-formula.sh - Update Homebrew formula after a GoReleaser release 4 4 # 5 - # Usage: ./scripts/update-homebrew-formula.sh <version> 5 + # Usage: ./scripts/update-homebrew-formula.sh <version> [--push] 6 6 # 7 7 # Example: ./scripts/update-homebrew-formula.sh v0.0.2 8 + # ./scripts/update-homebrew-formula.sh v0.0.2 --push 8 9 # 9 10 # This script: 10 - # 1. Downloads the source tarball from GitHub 11 - # 2. Calculates SHA256 checksum 12 - # 3. Generates updated formula snippet 11 + # 1. Downloads pre-built archives from Tangled for each platform 12 + # 2. Computes SHA256 checksums 13 + # 3. Generates the updated formula 14 + # 4. Optionally clones the homebrew-tap repo, commits, and pushes 15 + # 16 + # If GoReleaser dist/ directory exists locally, checksums are read from there instead. 13 17 # 14 18 15 19 set -euo pipefail 16 20 17 - # Colors for output 18 21 RED='\033[0;31m' 19 22 GREEN='\033[0;32m' 20 23 YELLOW='\033[1;33m' 21 - NC='\033[0m' # No Color 24 + NC='\033[0m' 25 + 26 + TANGLED_REPO="https://tangled.org/evan.jarrett.net/at-container-registry" 27 + TAP_REPO="https://tangled.org/evan.jarrett.net/homebrew-tap" 28 + BINARY_NAME="docker-credential-atcr" 29 + FORMULA_PATH="Formula/docker-credential-atcr.rb" 30 + 31 + PLATFORMS=( 32 + "Darwin_arm64" 33 + "Darwin_x86_64" 34 + "Linux_arm64" 35 + "Linux_x86_64" 36 + ) 22 37 23 - # Check arguments 24 - if [ $# -ne 1 ]; then 38 + if [ $# -lt 1 ]; then 25 39 echo -e "${RED}Error: Missing required argument${NC}" 26 - echo "Usage: $0 <version>" 40 + echo "Usage: $0 <version> [--push]" 27 41 echo "" 28 42 echo "Example: $0 v0.0.2" 29 - echo " $0 0.0.2 (v prefix is optional)" 43 + echo " $0 v0.0.2 --push" 30 44 exit 1 31 45 fi 32 46 33 47 VERSION="$1" 48 + PUSH=false 49 + if [ "${2:-}" = "--push" ]; then 50 + PUSH=true 51 + fi 34 52 35 53 # Add 'v' prefix if not present 36 54 if [[ ! "$VERSION" =~ ^v ]]; then 37 55 VERSION="v${VERSION}" 38 56 fi 57 + VERSION_NO_V="${VERSION#v}" 39 58 40 - echo -e "${GREEN}Updating Homebrew formula for version ${VERSION}${NC}" 59 + echo -e "${GREEN}Updating Homebrew formula for ${VERSION}${NC}" 41 60 echo "" 42 61 43 - # GitHub repository details 44 - GITHUB_REPO="atcr-io/atcr" 45 - TARBALL_URL="https://github.com/${GITHUB_REPO}/archive/refs/tags/${VERSION}.tar.gz" 46 - 47 - # Create temporary directory 48 62 TEMP_DIR=$(mktemp -d) 49 63 trap 'rm -rf "$TEMP_DIR"' EXIT 50 64 51 - TARBALL_FILE="${TEMP_DIR}/${VERSION}.tar.gz" 52 - 53 - echo -e "${YELLOW}Downloading source tarball...${NC}" 54 - echo "URL: ${TARBALL_URL}" 65 + # Compute SHA256 for each platform archive 66 + declare -A CHECKSUMS 55 67 56 - if curl -sSfL -o "$TARBALL_FILE" "$TARBALL_URL"; then 57 - # Calculate SHA256 68 + sha256_of_file() { 58 69 if command -v sha256sum &> /dev/null; then 59 - CHECKSUM=$(sha256sum "$TARBALL_FILE" | awk '{print $1}') 70 + sha256sum "$1" | awk '{print $1}' 60 71 elif command -v shasum &> /dev/null; then 61 - CHECKSUM=$(shasum -a 256 "$TARBALL_FILE" | awk '{print $1}') 72 + shasum -a 256 "$1" | awk '{print $1}' 62 73 else 63 - echo -e "${RED}Error: sha256sum or shasum command not found${NC}" 74 + echo -e "${RED}Error: sha256sum or shasum not found${NC}" >&2 64 75 exit 1 65 76 fi 77 + } 66 78 67 - echo -e "${GREEN}✓ Downloaded successfully${NC}" 68 - echo "SHA256: $CHECKSUM" 79 + # Check if GoReleaser dist/ has the archives locally 80 + GORELEASER_DIST="dist" 81 + if [ -f "${GORELEASER_DIST}/checksums.txt" ]; then 82 + echo -e "${YELLOW}Using local GoReleaser dist/ for checksums${NC}" 83 + for platform in "${PLATFORMS[@]}"; do 84 + archive="${BINARY_NAME}_${VERSION_NO_V}_${platform}.tar.gz" 85 + checksum=$(grep "${archive}" "${GORELEASER_DIST}/checksums.txt" | awk '{print $1}') 86 + if [ -z "$checksum" ]; then 87 + echo -e "${RED}Missing checksum for ${archive} in dist/checksums.txt${NC}" 88 + exit 1 89 + fi 90 + CHECKSUMS[$platform]="$checksum" 91 + echo -e " ${GREEN}✓${NC} ${platform}: ${checksum}" 92 + done 69 93 else 70 - echo -e "${RED}✗ Failed to download source tarball${NC}" 71 - echo "" 72 - echo "Make sure the tag ${VERSION} exists on GitHub:" 73 - echo " https://github.com/${GITHUB_REPO}/releases/tag/${VERSION}" 74 - echo "" 75 - echo "If you haven't pushed the tag yet, run:" 76 - echo " git tag ${VERSION}" 77 - echo " git push origin ${VERSION}" 78 - exit 1 94 + echo -e "${YELLOW}Downloading archives from Tangled to compute checksums...${NC}" 95 + for platform in "${PLATFORMS[@]}"; do 96 + archive="${BINARY_NAME}_${VERSION_NO_V}_${platform}.tar.gz" 97 + url="${TANGLED_REPO}/tags/${VERSION}/download/${archive}" 98 + dest="${TEMP_DIR}/${archive}" 99 + 100 + echo -n " ${platform}... " 101 + if curl -sSfL -o "$dest" "$url"; then 102 + CHECKSUMS[$platform]=$(sha256_of_file "$dest") 103 + echo -e "${GREEN}✓${NC} ${CHECKSUMS[$platform]}" 104 + else 105 + echo -e "${RED}✗ Failed to download${NC}" 106 + echo " URL: ${url}" 107 + exit 1 108 + fi 109 + done 79 110 fi 80 111 81 112 echo "" 82 - echo "======================================================================" 83 - echo "Copy the following to Formula/docker-credential-atcr.rb:" 84 - echo "======================================================================" 85 - echo "" 86 113 87 - cat << EOF 88 - url "https://github.com/${GITHUB_REPO}/archive/refs/tags/${VERSION}.tar.gz" 89 - sha256 "${CHECKSUM}" 114 + # Generate the formula 115 + FORMULA=$(cat <<RUBY 116 + # typed: false 117 + # frozen_string_literal: true 118 + 119 + class DockerCredentialAtcr < Formula 120 + desc "Docker credential helper for ATCR (ATProto Container Registry)" 121 + homepage "https://atcr.io" 122 + version "${VERSION_NO_V}" 90 123 license "MIT" 91 - head "https://github.com/${GITHUB_REPO}.git", branch: "main" 92 - EOF 124 + 125 + on_macos do 126 + on_arm do 127 + url "${TANGLED_REPO}/tags/${VERSION}/download/${BINARY_NAME}_${VERSION_NO_V}_Darwin_arm64.tar.gz" 128 + sha256 "${CHECKSUMS[Darwin_arm64]}" 129 + end 130 + on_intel do 131 + url "${TANGLED_REPO}/tags/${VERSION}/download/${BINARY_NAME}_${VERSION_NO_V}_Darwin_x86_64.tar.gz" 132 + sha256 "${CHECKSUMS[Darwin_x86_64]}" 133 + end 134 + end 135 + 136 + on_linux do 137 + on_arm do 138 + url "${TANGLED_REPO}/tags/${VERSION}/download/${BINARY_NAME}_${VERSION_NO_V}_Linux_arm64.tar.gz" 139 + sha256 "${CHECKSUMS[Linux_arm64]}" 140 + end 141 + on_intel do 142 + url "${TANGLED_REPO}/tags/${VERSION}/download/${BINARY_NAME}_${VERSION_NO_V}_Linux_x86_64.tar.gz" 143 + sha256 "${CHECKSUMS[Linux_x86_64]}" 144 + end 145 + end 146 + 147 + def install 148 + bin.install "docker-credential-atcr" 149 + end 150 + 151 + test do 152 + assert_match version.to_s, shell_output("#{bin}/docker-credential-atcr version 2>&1") 153 + end 154 + 155 + def caveats 156 + <<~EOS 157 + To configure Docker to use ATCR credential helper, add the following 158 + to your ~/.docker/config.json: 159 + 160 + { 161 + "credHelpers": { 162 + "atcr.io": "atcr" 163 + } 164 + } 165 + 166 + Or run: docker-credential-atcr configure-docker 167 + 168 + To authenticate with ATCR: 169 + docker push atcr.io/<your-handle>/<image>:latest 170 + 171 + Configuration is stored in: ~/.atcr/config.json 172 + EOS 173 + end 174 + end 175 + RUBY 176 + ) 177 + 178 + # Write to local formula 179 + echo "$FORMULA" > "${FORMULA_PATH}" 180 + echo -e "${GREEN}✓ Updated ${FORMULA_PATH}${NC}" 181 + 182 + if [ "$PUSH" = true ]; then 183 + echo "" 184 + echo -e "${YELLOW}Pushing to homebrew-tap repo...${NC}" 185 + 186 + TAP_DIR="${TEMP_DIR}/homebrew-tap" 187 + git clone "$TAP_REPO" "$TAP_DIR" 2>/dev/null || { 188 + echo -e "${YELLOW}Tap repo not found, initializing new repo${NC}" 189 + mkdir -p "$TAP_DIR" 190 + cd "$TAP_DIR" 191 + git init 192 + git remote add origin "$TAP_REPO" 193 + } 194 + 195 + mkdir -p "${TAP_DIR}/Formula" 196 + cp "${FORMULA_PATH}" "${TAP_DIR}/Formula/" 93 197 94 - echo "" 95 - echo "======================================================================" 198 + cd "$TAP_DIR" 199 + git add Formula/docker-credential-atcr.rb 200 + git commit -m "Update docker-credential-atcr to ${VERSION}" 201 + git push origin HEAD 202 + 203 + echo -e "${GREEN}✓ Pushed to ${TAP_REPO}${NC}" 204 + else 205 + echo "" 206 + echo -e "${YELLOW}Next steps:${NC}" 207 + echo "1. Review the formula: ${FORMULA_PATH}" 208 + echo "2. Push to your homebrew-tap repo on Tangled:" 209 + echo " cd /path/to/homebrew-tap" 210 + echo " cp ${FORMULA_PATH} Formula/" 211 + echo " git add Formula/ && git commit -m 'Update to ${VERSION}' && git push" 212 + echo "" 213 + echo "Or re-run with --push to do this automatically:" 214 + echo " $0 ${VERSION} --push" 215 + fi 216 + 96 217 echo "" 97 - echo -e "${YELLOW}Next steps:${NC}" 98 - echo "1. Update Formula/docker-credential-atcr.rb with the url and sha256 above" 99 - echo "2. Test the formula locally:" 100 - echo " brew install --build-from-source Formula/docker-credential-atcr.rb" 101 - echo " docker-credential-atcr version" 102 - echo "3. Commit and push to your atcr-io/homebrew-tap repository:" 103 - echo " cd /path/to/homebrew-tap" 104 - echo " cp Formula/docker-credential-atcr.rb ." 105 - echo " git add docker-credential-atcr.rb" 106 - echo " git commit -m \"Update docker-credential-atcr to ${VERSION}\"" 107 - echo " git push" 108 - echo "4. Users can upgrade with:" 109 - echo " brew update" 110 - echo " brew upgrade docker-credential-atcr" 218 + echo -e "${GREEN}Users can install/upgrade with:${NC}" 219 + echo " brew tap atcr/tap ${TAP_REPO}" 220 + echo " brew install docker-credential-atcr" 221 + echo " brew upgrade docker-credential-atcr"