Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func main() {
commands.LsCommand,
commands.WebCommand,
commands.AliasCommand,
commands.DotfilesCommand,
commands.DoctorCommand,
commands.QueryCommand,
}
Expand Down
123 changes: 123 additions & 0 deletions commands/dotfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package commands

import (
"fmt"
"os"

"github.com/malamtime/cli/model"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)

var DotfilesCommand *cli.Command = &cli.Command{
Name: "dotfiles",
Usage: "manage dotfiles configuration",
Subcommands: []*cli.Command{
{
Name: "push",
Usage: "push dotfiles to server",
Action: pushDotfiles,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "apps",
Aliases: []string{"a"},
Usage: "specify which apps to push (nvim, fish, git, zsh, bash, ghostty). If empty, pushes all",
},
},
},
},
OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
return nil
},
Comment on lines +31 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Swallowing usage errors by returning nil provides a poor user experience, as users won't be notified of incorrect command usage. It's better to remove this OnUsageError handler and let the urfave/cli framework provide its default, helpful error messages.

}

func pushDotfiles(c *cli.Context) error {
ctx, span := commandTracer.Start(c.Context, "dotfiles-push", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling SetupLogger here can lead to resource leaks. The implementation in commands/logger.go opens a file handle but does not handle being called multiple times within the same process execution. Logger initialization should be performed once, centrally, at the application's startup in main().

logrus.SetLevel(logrus.TraceLevel)

apps := c.StringSlice("apps")
span.SetAttributes(attribute.StringSlice("apps", apps))

config, err := configService.ReadConfigFile(ctx)
if err != nil {
logrus.Errorln(err)
return err
}

if config.Token == "" {
return fmt.Errorf("no token found, please run 'shelltime auth login' first")
}

mainEndpoint := model.Endpoint{
APIEndpoint: config.APIEndpoint,
Token: config.Token,
}

// Initialize all available app handlers
allApps := []model.DotfileApp{
model.NewNvimApp(),
model.NewFishApp(),
model.NewGitApp(),
model.NewZshApp(),
model.NewBashApp(),
model.NewGhosttyApp(),
}

// Filter apps based on user input
var selectedApps []model.DotfileApp
if len(apps) == 0 {
// If no apps specified, use all
selectedApps = allApps
} else {
// Filter based on user selection
appMap := make(map[string]model.DotfileApp)
for _, app := range allApps {
appMap[app.Name()] = app
}

for _, appName := range apps {
if app, ok := appMap[appName]; ok {
selectedApps = append(selectedApps, app)
} else {
logrus.Warnf("Unknown app: %s", appName)
}
}
}

// Collect all dotfiles
var allDotfiles []model.DotfileItem
for _, app := range selectedApps {
logrus.Infof("Collecting dotfiles for %s", app.Name())
dotfiles, err := app.CollectDotfiles(ctx)
if err != nil {
logrus.Errorf("Failed to collect dotfiles for %s: %v", app.Name(), err)
continue
}
allDotfiles = append(allDotfiles, dotfiles...)
}

if len(allDotfiles) == 0 {
logrus.Infoln("No dotfiles found to push")
return nil
}

// Send to server
logrus.Infof("Pushing %d dotfiles to server", len(allDotfiles))
userID, err := model.SendDotfilesToServer(ctx, mainEndpoint, allDotfiles)
if err != nil {
logrus.Errorln("Failed to send dotfiles to server:", err)
return err
}

// Generate web link for managing dotfiles
webLink := fmt.Sprintf("%s/users/%d/settings/dotfiles", config.WebEndpoint, userID)
logrus.Infof("Successfully pushed dotfiles. Manage them at: %s", webLink)
fmt.Printf("\n✅ Successfully pushed %d dotfiles to server\n", len(allDotfiles))
fmt.Printf("📁 Manage your dotfiles at: %s\n", webLink)

return nil
}
55 changes: 55 additions & 0 deletions model/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/pelletier/go-toml/v2"
Expand All @@ -25,6 +26,41 @@ func NewConfigService(configFilePath string) ConfigService {
}
}

// mergeConfig merges local config settings into the base config
// Local settings override base settings when they are non-zero values
func mergeConfig(base, local *ShellTimeConfig) {
if local.Token != "" {
base.Token = local.Token
}
if local.APIEndpoint != "" {
base.APIEndpoint = local.APIEndpoint
}
if local.WebEndpoint != "" {
base.WebEndpoint = local.WebEndpoint
}
if local.FlushCount > 0 {
base.FlushCount = local.FlushCount
}
if local.GCTime > 0 {
base.GCTime = local.GCTime
}
if local.DataMasking != nil {
base.DataMasking = local.DataMasking
}
if local.EnableMetrics != nil {
base.EnableMetrics = local.EnableMetrics
}
if local.Encrypted != nil {
base.Encrypted = local.Encrypted
}
if local.AI != nil {
base.AI = local.AI
}
if len(local.Endpoints) > 0 {
base.Endpoints = local.Endpoints
}
}

func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeConfig, err error) {
ctx, span := modelTracer.Start(ctx, "config.read")
defer span.End()
Expand All @@ -42,6 +78,25 @@ func (cs *configService) ReadConfigFile(ctx context.Context) (config ShellTimeCo
return
}

// Check for local config file and merge if exists
// Extract the file extension and construct local config filename
ext := filepath.Ext(configFile)
if ext != "" {
// Get the base name without extension
baseName := strings.TrimSuffix(configFile, ext)
// Construct local config filename: baseName + ".local" + ext
localConfigFile := baseName + ".local" + ext

if localConfig, localErr := os.ReadFile(localConfigFile); localErr == nil {
// Parse local config and merge with base config
var localSettings ShellTimeConfig
if unmarshalErr := toml.Unmarshal(localConfig, &localSettings); unmarshalErr == nil {
// Merge local settings into base config
mergeConfig(&config, &localSettings)
}
}
}

// default 10 and at least 3 for performance reason
if config.FlushCount == 0 {
config.FlushCount = 10
Expand Down
144 changes: 144 additions & 0 deletions model/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package model

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReadConfigFileWithLocal(t *testing.T) {
// Create a temporary directory for test configs
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create base config file
baseConfigPath := filepath.Join(tmpDir, "config.toml")
baseConfig := `Token = 'base-token'
APIEndpoint = 'https://api.base.com'
WebEndpoint = 'https://base.com'
FlushCount = 5
GCTime = 7
dataMasking = false
enableMetrics = false
encrypted = false`
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
require.NoError(t, err)

// Create local config file that overrides some settings
localConfigPath := filepath.Join(tmpDir, "config.local.toml")
localConfig := `Token = 'local-token'
APIEndpoint = 'https://api.local.com'
FlushCount = 10
dataMasking = true`
err = os.WriteFile(localConfigPath, []byte(localConfig), 0644)
require.NoError(t, err)

// Test reading config with local override
cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

// Verify local config overrides base config
assert.Equal(t, "local-token", config.Token, "Token should be overridden by local config")
assert.Equal(t, "https://api.local.com", config.APIEndpoint, "APIEndpoint should be overridden by local config")
assert.Equal(t, 10, config.FlushCount, "FlushCount should be overridden by local config")
assert.True(t, *config.DataMasking, "DataMasking should be overridden by local config")

// Verify base config values that weren't overridden
assert.Equal(t, "https://base.com", config.WebEndpoint, "WebEndpoint should keep base value")
assert.Equal(t, 7, config.GCTime, "GCTime should keep base value")
assert.False(t, *config.EnableMetrics, "EnableMetrics should keep base value")
assert.False(t, *config.Encrypted, "Encrypted should keep base value")
}

func TestReadConfigFileWithoutLocal(t *testing.T) {
// Create a temporary directory for test configs
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create only base config file (no local file)
baseConfigPath := filepath.Join(tmpDir, "config.toml")
baseConfig := `Token = 'base-token'
APIEndpoint = 'https://api.base.com'
WebEndpoint = 'https://base.com'
FlushCount = 5
GCTime = 7`
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
require.NoError(t, err)

// Test reading config without local file
cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

// Verify base config values are used
assert.Equal(t, "base-token", config.Token)
assert.Equal(t, "https://api.base.com", config.APIEndpoint)
assert.Equal(t, "https://base.com", config.WebEndpoint)
assert.Equal(t, 5, config.FlushCount)
assert.Equal(t, 7, config.GCTime)
}

func TestReadConfigFileWithDifferentExtensions(t *testing.T) {
testCases := []struct {
name string
configFile string
localFile string
}{
{
name: "TOML files",
configFile: "config.toml",
localFile: "config.local.toml",
},
{
name: "Custom config name",
configFile: "shelltime-config.toml",
localFile: "shelltime-config.local.toml",
},
{
name: "Different extension",
configFile: "settings.conf",
localFile: "settings.local.conf",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a temporary directory for test configs
tmpDir, err := os.MkdirTemp("", "shelltime-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create base config file
baseConfigPath := filepath.Join(tmpDir, tc.configFile)
baseConfig := `Token = 'base-token'
APIEndpoint = 'https://api.base.com'
FlushCount = 5`
err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644)
require.NoError(t, err)

// Create local config file
localConfigPath := filepath.Join(tmpDir, tc.localFile)
localConfig := `Token = 'local-token'
FlushCount = 10`
err = os.WriteFile(localConfigPath, []byte(localConfig), 0644)
require.NoError(t, err)

// Test reading config with local override
cs := NewConfigService(baseConfigPath)
config, err := cs.ReadConfigFile(context.Background())
require.NoError(t, err)

// Verify local config overrides base config
assert.Equal(t, "local-token", config.Token, "Token should be overridden by local config for %s", tc.name)
assert.Equal(t, 10, config.FlushCount, "FlushCount should be overridden by local config for %s", tc.name)
assert.Equal(t, "https://api.base.com", config.APIEndpoint, "APIEndpoint should keep base value for %s", tc.name)
})
}
}
Loading