This commit is contained in:
2025-09-18 11:12:41 -07:00
parent c00ea67f31
commit 026d8873bb
56 changed files with 2321 additions and 2891 deletions

View File

@@ -0,0 +1,15 @@
package activities
import (
"github.com/spf13/cobra"
)
var ActivitiesCmd = &cobra.Command{
Use: "activities",
Short: "Manage activities from Garmin Connect",
Long: `Commands for listing, downloading, and managing activities from Garmin Connect`,
}
func init() {
ActivitiesCmd.AddCommand(ListCmd)
}

View File

@@ -0,0 +1,22 @@
package activities
import (
"time"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/types"
)
type ActivitiesClient interface {
GetActivities(start, end time.Time) ([]types.Activity, error)
Login(email, password string) error
SaveSession(filename string) error
}
var newClient func(domain string) (ActivitiesClient, error) = func(domain string) (ActivitiesClient, error) {
return client.NewClient(domain)
}
func SetClient(f func(domain string) (ActivitiesClient, error)) {
newClient = f
}

View File

@@ -0,0 +1,131 @@
package activities
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"time"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
"github.com/sstent/go-garth/garth/types"
)
var (
outputFormat string
startDate string
endDate string
)
var ListCmd = &cobra.Command{
Use: "list",
Short: "List activities from Garmin Connect",
Long: `List activities with filtering by date range and multiple output formats`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient("")
if err != nil {
return fmt.Errorf("client creation failed: %w", err)
}
start, end, err := getDateFilter()
if err != nil {
return fmt.Errorf("invalid date filter: %w", err)
}
activities, err := c.GetActivities(start, end)
if err != nil {
return fmt.Errorf("failed to get activities: %w", err)
}
switch outputFormat {
case "table":
renderTable(activities)
case "json":
if err := renderJSON(activities); err != nil {
return err
}
case "csv":
if err := renderCSV(activities); err != nil {
return err
}
default:
return fmt.Errorf("invalid output format: %s", outputFormat)
}
return nil
},
}
func init() {
ListCmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format (table|json|csv)")
ListCmd.Flags().StringVar(&startDate, "start", "", "Start date (YYYY-MM-DD)")
ListCmd.Flags().StringVar(&endDate, "end", "", "End date (YYYY-MM-DD)")
}
func getDateFilter() (time.Time, time.Time, error) {
var start, end time.Time
var err error
if startDate != "" {
start, err = time.Parse("2006-01-02", startDate)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date format: %w", err)
}
}
if endDate != "" {
end, err = time.Parse("2006-01-02", endDate)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date format: %w", err)
}
}
return start, end, nil
}
func renderTable(activities []types.Activity) {
table := tablewriter.NewWriter(os.Stdout)
table.Header("ID", "Name", "Type", "Date", "Distance (km)", "Duration")
for _, a := range activities {
table.Append(
fmt.Sprint(a.ActivityID),
a.ActivityName,
a.ActivityType.TypeKey,
a.StartTimeLocal,
fmt.Sprintf("%.2f", a.Distance/1000),
time.Duration(a.Duration*float64(time.Second)).String(),
)
}
table.Render()
}
func renderJSON(activities []types.Activity) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(activities)
}
func renderCSV(activities []types.Activity) error {
w := csv.NewWriter(os.Stdout)
defer w.Flush()
if err := w.Write([]string{"ID", "Name", "Type", "Date", "Distance (km)", "Duration"}); err != nil {
return err
}
for _, a := range activities {
record := []string{
fmt.Sprint(a.ActivityID),
a.ActivityName,
a.ActivityType.TypeKey,
a.StartTimeLocal,
fmt.Sprintf("%.2f", a.Distance/1000),
time.Duration(a.Duration * float64(time.Second)).String(),
}
if err := w.Write(record); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,193 @@
package activities
import (
"bytes"
"os"
"testing"
"time"
"github.com/sstent/go-garth/garth/types"
"github.com/stretchr/testify/assert"
)
type mockClient struct{}
func (m *mockClient) GetActivities(start, end time.Time) ([]types.Activity, error) {
return []types.Activity{
{
ActivityID: 123,
ActivityName: "Morning Run",
ActivityType: types.ActivityType{
TypeKey: "running",
},
StartTimeLocal: "2025-09-18T08:30:00",
Distance: 5000,
Duration: 1800,
},
}, nil
}
func (m *mockClient) Login(email, password string) error {
return nil
}
func (m *mockClient) SaveSession(filename string) error {
return nil
}
func TestRenderTable(t *testing.T) {
activities := []types.Activity{
{
ActivityID: 123,
ActivityName: "Morning Run",
ActivityType: types.ActivityType{
TypeKey: "running",
},
StartTimeLocal: "2025-09-18T08:30:00",
Distance: 5000,
Duration: 1800,
},
}
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
renderTable(activities)
w.Close()
os.Stdout = old
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
assert.Contains(t, output, "123")
assert.Contains(t, output, "Morning Run")
assert.Contains(t, output, "running")
}
func TestRenderJSON(t *testing.T) {
activities := []types.Activity{
{
ActivityID: 123,
ActivityName: "Morning Run",
ActivityType: types.ActivityType{
TypeKey: "running",
},
StartTimeLocal: "2025-09-18T08:30:00",
Distance: 5000,
Duration: 1800,
},
}
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := renderJSON(activities)
w.Close()
os.Stdout = old
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
assert.NoError(t, err)
assert.Contains(t, output, `"activityId": 123`)
assert.Contains(t, output, `"activityName": "Morning Run"`)
}
func TestRenderCSV(t *testing.T) {
activities := []types.Activity{
{
ActivityID: 123,
ActivityName: "Morning Run",
ActivityType: types.ActivityType{
TypeKey: "running",
},
StartTimeLocal: "2025-09-18T08:30:00",
Distance: 5000,
Duration: 1800,
},
}
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := renderCSV(activities)
w.Close()
os.Stdout = old
var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()
assert.NoError(t, err)
assert.Contains(t, output, "123,Morning Run,running")
}
func TestGetDateFilter(t *testing.T) {
tests := []struct {
name string
startDate string
endDate string
wantStart bool
wantEnd bool
}{
{
name: "no dates",
startDate: "",
endDate: "",
wantStart: false,
wantEnd: false,
},
{
name: "start date only",
startDate: "2025-09-18",
endDate: "",
wantStart: true,
wantEnd: false,
},
{
name: "both dates",
startDate: "2025-09-18",
endDate: "2025-09-20",
wantStart: true,
wantEnd: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original values
origStart, origEnd := startDate, endDate
defer func() {
startDate, endDate = origStart, origEnd
}()
startDate = tt.startDate
endDate = tt.endDate
start, end := getDateFilter()
if tt.wantStart {
assert.False(t, start.IsZero(), "expected non-zero start time")
} else {
assert.True(t, start.IsZero(), "expected zero start time")
}
if tt.wantEnd {
assert.False(t, end.IsZero(), "expected non-zero end time")
} else {
assert.True(t, end.IsZero(), "expected zero end time")
}
})
}
}

View File

@@ -0,0 +1,57 @@
package auth
import (
"fmt"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/sstent/go-garth/garth/client"
)
var (
email string
password string
)
var LoginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate with Garmin Connect",
Long: `Authenticate using Garmin Connect credentials and save session tokens`,
RunE: func(cmd *cobra.Command, args []string) error {
if password == "" {
fmt.Print("Enter password: ")
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("password input failed: %w", err)
}
password = string(bytePassword)
fmt.Println()
}
c, err := client.NewClient("")
if err != nil {
return fmt.Errorf("client creation failed: %w", err)
}
if err := c.Login(email, password); err != nil {
return fmt.Errorf("login failed: %w", err)
}
if err := c.SaveSession("session.json"); err != nil {
return fmt.Errorf("session save failed: %w", err)
}
fmt.Println("Logged in successfully. Session saved to session.json")
return nil
},
}
func init() {
LoginCmd.Flags().StringVarP(&email, "email", "e", "", "Garmin login email")
LoginCmd.Flags().StringVarP(&password, "password", "p", "", "Garmin login password (optional, will prompt if empty)")
// Mark required flags
LoginCmd.MarkFlagRequired("email")
}

View File

@@ -0,0 +1,38 @@
package auth
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var LogoutCmd = &cobra.Command{
Use: "logout",
Short: "Logout and remove saved session",
Long: `Remove the saved session file and clear authentication tokens`,
RunE: func(cmd *cobra.Command, args []string) error {
sessionPath, _ := cmd.Flags().GetString("session")
if sessionPath == "" {
sessionPath = "session.json"
}
// Check if session file exists
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
fmt.Printf("No session file found at %s\n", sessionPath)
return nil
}
// Remove session file
if err := os.Remove(sessionPath); err != nil {
return fmt.Errorf("failed to remove session file: %w", err)
}
fmt.Printf("Logged out successfully. Session file removed: %s\n", sessionPath)
return nil
},
}
func init() {
LogoutCmd.Flags().StringP("session", "s", "session.json", "Session file path to remove")
}

View File

@@ -0,0 +1,59 @@
package auth
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/sstent/go-garth/garth/client"
)
var StatusCmd = &cobra.Command{
Use: "status",
Short: "Check authentication status",
Long: `Check if you are currently authenticated with Garmin Connect`,
RunE: func(cmd *cobra.Command, args []string) error {
sessionPath, _ := cmd.Flags().GetString("session")
if sessionPath == "" {
sessionPath = "session.json"
}
// Check if session file exists
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
fmt.Printf("❌ Not authenticated\n")
fmt.Printf("Session file not found: %s\n", sessionPath)
fmt.Printf("Run 'garth auth login' to authenticate\n")
return nil
}
// Try to create client and load session
c, err := client.NewClient("")
if err != nil {
return fmt.Errorf("client creation failed: %w", err)
}
if err := c.LoadSession(sessionPath); err != nil {
fmt.Printf("❌ Session file exists but is invalid\n")
fmt.Printf("Error: %v\n", err)
fmt.Printf("Run 'garth auth login' to re-authenticate\n")
return nil
}
// Try to make a simple authenticated request to verify session
if _, err := c.GetUserProfile(); err != nil {
fmt.Printf("❌ Session exists but authentication failed\n")
fmt.Printf("Error: %v\n", err)
fmt.Printf("Run 'garth auth login' to re-authenticate\n")
return nil
}
fmt.Printf("✅ Authenticated\n")
fmt.Printf("Session file: %s\n", sessionPath)
return nil
},
}
func init() {
StatusCmd.Flags().StringP("session", "s", "session.json", "Session file path to check")
}

111
cmd/garth/cmd/root.go Normal file
View File

@@ -0,0 +1,111 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/sstent/go-garth/cmd/garth/cmd/activities"
"github.com/sstent/go-garth/cmd/garth/cmd/auth"
garthclient "github.com/sstent/go-garth/garth/client"
)
var (
cfgFile string
garthClient *garthclient.Client
)
var rootCmd = &cobra.Command{
Use: "garth",
Short: "Garmin Connect CLI client",
Long: `A comprehensive CLI client for Garmin Connect.
Access your activities, health data, and statistics from the command line.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
initClient()
},
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Add subcommands
rootCmd.AddCommand(auth.LoginCmd)
rootCmd.AddCommand(auth.LogoutCmd)
rootCmd.AddCommand(auth.StatusCmd)
rootCmd.AddCommand(activities.ActivitiesCmd)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.garth/config.yaml)")
rootCmd.PersistentFlags().StringP("format", "f", "table",
"output format (table, json, csv)")
rootCmd.PersistentFlags().BoolP("verbose", "v", false,
"verbose output")
rootCmd.PersistentFlags().StringP("session", "s", "",
"session file path")
viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format"))
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
viper.BindPFlag("session", rootCmd.PersistentFlags().Lookup("session"))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
configDir := filepath.Join(home, ".garth")
if err := os.MkdirAll(configDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
os.Exit(1)
}
viper.AddConfigPath(configDir)
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
os.Exit(1)
}
}
}
func initClient() {
var err error
domain := viper.GetString("domain")
if domain == "" {
domain = "garmin.com"
}
garthClient, err = garthclient.NewClient(domain)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating client: %v\n", err)
os.Exit(1)
}
sessionPath := viper.GetString("session")
if sessionPath == "" {
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
os.Exit(1)
}
sessionPath = filepath.Join(home, ".garth", "session.json")
}
if err := garthClient.LoadSession(sessionPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not load session: %v\n", err)
}
}

View File

@@ -8,8 +8,8 @@ import (
"os"
"time"
"garmin-connect/garth"
"garmin-connect/garth/credentials"
auth "github.com/sstent/go-garth/internal/auth"
garmin "github.com/sstent/go-garth/pkg/garmin"
)
func main() {
@@ -23,13 +23,13 @@ func main() {
flag.Parse()
// Load credentials from .env file
email, password, domain, err := credentials.LoadEnvCredentials()
email, password, domain, err := auth.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garth.NewClient(domain)
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
@@ -77,7 +77,7 @@ func main() {
displayActivities(activities)
}
func outputTokensJSON(c *garth.Client) {
func outputTokensJSON(c *garmin.Client) {
tokens := struct {
OAuth1 *garth.OAuth1Token `json:"oauth1"`
OAuth2 *garth.OAuth2Token `json:"oauth2"`