▍ humdrum codex / dots v1.0.0

feat: token storage (keychain + file fallback) and dots auth command

1e954ed2b10cf7afd9070bb94dd6c320b2bf221c
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 17:14

parent 5ab15332

4 files changed

cmd/dots/main.go +3 −0
@@ -5,6 +5,8 @@ 	"fmt"
 	"os"
 
 	"github.com/spf13/cobra"
+
+	"github.com/humdrum-tiv/dots-cli/internal/cli"
 )
 
 func newRootCmd() *cobra.Command {
@@ -14,6 +16,7 @@ 		Short:         "Log your day from the terminal",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 	}
+	root.AddCommand(cli.NewAuthCmd())
 	return root
 }
 
internal/auth/store.go +56 −0
@@ -0,0 +1,56 @@
+package auth
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/zalando/go-keyring"
+)
+
+const service = "dots-cli"
+const account = "token"
+
+// ErrNoToken means no token is stored anywhere.
+var ErrNoToken = errors.New("no token stored")
+
+func tokenDir() string {
+	if d := os.Getenv("DOTS_TOKEN_DIR"); d != "" {
+		return d
+	}
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".config", "dots")
+}
+
+func saveTokenFile(token string) error {
+	dir := tokenDir()
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return err
+	}
+	return os.WriteFile(filepath.Join(dir, "token"), []byte(token+"\n"), 0o600)
+}
+
+func loadTokenFile() (string, error) {
+	b, err := os.ReadFile(filepath.Join(tokenDir(), "token"))
+	if err != nil {
+		return "", ErrNoToken
+	}
+	return strings.TrimSpace(string(b)), nil
+}
+
+// SaveToken prefers the OS keychain, falling back to a 0600 file.
+func SaveToken(token string) error {
+	if err := keyring.Set(service, account, token); err == nil {
+		return nil
+	}
+	return saveTokenFile(token)
+}
+
+// LoadToken checks the keychain first, then the fallback file.
+func LoadToken() (string, error) {
+	if t, err := keyring.Get(service, account); err == nil && t != "" {
+		return t, nil
+	}
+	return loadTokenFile()
+}
internal/auth/store_test.go +34 −0
@@ -0,0 +1,34 @@
+package auth
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFileFallbackRoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("DOTS_TOKEN_DIR", dir) // test hook: overrides ~/.config/dots
+
+	if err := saveTokenFile("abc123"); err != nil {
+		t.Fatal(err)
+	}
+	got, err := loadTokenFile()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != "abc123" {
+		t.Fatalf("got %q", got)
+	}
+	info, _ := os.Stat(filepath.Join(dir, "token"))
+	if info.Mode().Perm() != 0o600 {
+		t.Fatalf("perm %v", info.Mode().Perm())
+	}
+}
+
+func TestLoadTokenFileMissing(t *testing.T) {
+	t.Setenv("DOTS_TOKEN_DIR", t.TempDir())
+	if _, err := loadTokenFile(); err == nil {
+		t.Fatal("want error for missing token file")
+	}
+}
internal/cli/auth.go +76 −0
@@ -0,0 +1,76 @@
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"os/exec"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"github.com/humdrum-tiv/dots-cli/internal/api"
+	"github.com/humdrum-tiv/dots-cli/internal/auth"
+	"github.com/humdrum-tiv/dots-cli/internal/config"
+)
+
+// RequireClient loads the stored token and returns a ready API client.
+func RequireClient() (*api.Client, error) {
+	token, err := auth.LoadToken()
+	if err != nil {
+		return nil, fmt.Errorf("not linked — run `dots auth`")
+	}
+	return api.New(config.BaseURL(), token), nil
+}
+
+// Today returns the current date (YYYY-MM-DD) in the device timezone.
+func Today() string {
+	return time.Now().Format("2006-01-02")
+}
+
+// TZ returns the device IANA timezone name. If the device timezone is not
+// mappable to an IANA name, falls back to "UTC".
+func TZ() string {
+	if loc := time.Local.String(); loc != "Local" {
+		return loc
+	}
+	return "UTC"
+}
+
+func openBrowser(url string) {
+	if runtime.GOOS == "darwin" {
+		exec.Command("open", url).Start()
+	} else {
+		exec.Command("xdg-open", url).Start()
+	}
+}
+
+func NewAuthCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "auth",
+		Short: "Link this machine to your Dots account",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			pageURL := config.BaseURL() + "/cli"
+			fmt.Printf("Opening %s — generate a token there, then paste it here.\n", pageURL)
+			openBrowser(pageURL)
+			fmt.Print("Token: ")
+			reader := bufio.NewReader(os.Stdin)
+			line, _ := reader.ReadString('\n')
+			token := strings.TrimSpace(line)
+			if token == "" {
+				return fmt.Errorf("no token entered")
+			}
+			client := api.New(config.BaseURL(), token)
+			if _, err := client.GetDay(Today(), TZ()); err != nil {
+				return fmt.Errorf("token check failed: %w", err)
+			}
+			if err := auth.SaveToken(token); err != nil {
+				return fmt.Errorf("could not store token: %w", err)
+			}
+			fmt.Println("Linked. Try `dots today`.")
+			return nil
+		},
+	}
+}