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
+ },
+ }
+}