A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

vuln scanner fixes, major refactor of the credential helper.

+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"