Files
Evan JarrettandClaude Opus 4.8 8a556a5893 credhelper: key accounts by DID (v3 config) with lossless migration
The credential helper stored accounts keyed by handle, which broke on
handle renames and let the Docker `store` path (username+secret, no DID)
overwrite a good account with a DID-less one — how evan.jarrett.net on
buoy.cr ended up active with a blank DID.

Re-key everything by the stable DID, treating handle as a mutable display
label. DID is recovered client-side via standard AT-proto handle
resolution (DNS TXT _atproto.<handle> + HTTPS .well-known/atproto-did) —
no server change, no JWT, no auth, and no indigo pulled into the helper.

- resolve.go: stdlib handle->DID resolver
- config.go: v3 DID-keyed schema; find/activeAccount/upsert/rekey helpers;
  upsert never blanks a known DID; migrateV2toV3 re-keys existing files in
  place (no login lost; DID-less accounts stay provisional and self-heal)
- protocol.go: store resolves+preserves DID; get lazily backfills+re-keys;
  list reports the active account's handle (was arbitrary map iteration)
- status/switch/logout: display handle, key/compare by DID
- config_test.go: migration, upsert-never-blanks-DID, rekey, find, list,
  resolver parse helpers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 13:38:45 -05:00

204 lines
5.4 KiB
Go

package credhelper
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Status message styles (matching gh CLI conventions)
var (
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan
boldStyle = lipgloss.NewStyle().Bold(true)
)
// logSuccess prints a green ✓ prefixed message to stderr
func logSuccess(format string, a ...any) {
fmt.Fprintf(os.Stderr, "%s %s\n", successStyle.Render("✓"), fmt.Sprintf(format, a...))
}
// logWarning prints a yellow ! prefixed message to stderr
func logWarning(format string, a ...any) {
fmt.Fprintf(os.Stderr, "%s %s\n", warningStyle.Render("!"), fmt.Sprintf(format, a...))
}
// logInfo prints a cyan - prefixed message to stderr
func logInfo(format string, a ...any) {
fmt.Fprintf(os.Stderr, "%s %s\n", infoStyle.Render("-"), fmt.Sprintf(format, a...))
}
// logInfof prints a cyan - prefixed message to stderr without a trailing newline
func logInfof(format string, a ...any) {
fmt.Fprintf(os.Stderr, "%s %s", infoStyle.Render("-"), fmt.Sprintf(format, a...))
}
// bold renders text in bold
func bold(s string) string {
return boldStyle.Render(s)
}
// DockerDaemonConfig represents Docker's daemon.json configuration
type DockerDaemonConfig struct {
InsecureRegistries []string `json:"insecure-registries"`
}
// openBrowser opens the specified URL in the default browser
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform")
}
return cmd.Start()
}
// buildAppViewURL constructs the AppView URL with the appropriate protocol
func buildAppViewURL(serverURL string) string {
// If serverURL already has a scheme, use it as-is
if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") {
return serverURL
}
// Determine protocol based on Docker configuration and heuristics
if isInsecureRegistry(serverURL) {
return "http://" + serverURL
}
// Default to HTTPS (mirrors Docker's default behavior)
return "https://" + serverURL
}
// isInsecureRegistry checks if a registry should use HTTP instead of HTTPS
func isInsecureRegistry(serverURL string) bool {
// Check Docker's insecure-registries configuration
insecureRegistries := getDockerInsecureRegistries()
for _, reg := range insecureRegistries {
if reg == serverURL || reg == stripPort(serverURL) {
return true
}
}
// Fallback heuristics: localhost and private IPs
host := stripPort(serverURL)
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() {
return true
}
}
return false
}
// getDockerInsecureRegistries reads Docker's insecure-registries configuration
func getDockerInsecureRegistries() []string {
var paths []string
switch runtime.GOOS {
case "windows":
programData := os.Getenv("ProgramData")
if programData != "" {
paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json"))
}
default:
paths = append(paths, "/etc/docker/daemon.json")
if homeDir, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json"))
}
}
for _, path := range paths {
if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 {
return config.InsecureRegistries
}
}
return nil
}
// readDockerDaemonConfig reads and parses a Docker daemon.json file
func readDockerDaemonConfig(path string) *DockerDaemonConfig {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var config DockerDaemonConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil
}
return &config
}
// stripPort removes the port from a host:port string
func stripPort(hostPort string) string {
if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 {
if strings.Count(hostPort, ":") > 1 {
return hostPort
}
return hostPort[:colonIdx]
}
return hostPort
}
// isTerminal checks if the file is a terminal
func isTerminal(f *os.File) bool {
stat, err := f.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
// configDirOverride, when non-empty, replaces the default $HOME/<ConfigDirName>
// config directory. Test-only seam; unset in normal operation.
var configDirOverride string
// getConfigDir returns the per-brand config directory under $HOME, creating
// it if needed. The directory name comes from cfg.ConfigDirName.
func getConfigDir() string {
dir := configDirOverride
if dir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
dir = filepath.Join(homeDir, cfg.ConfigDirName)
}
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating %s directory: %v\n", cfg.ConfigDirName, err)
os.Exit(1)
}
return dir
}
// getConfigPath returns the path to the device configuration file
func getConfigPath() string {
return filepath.Join(getConfigDir(), "device.json")
}