diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 0d5ccf8..05ee580 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -102,6 +102,7 @@ func main() { commands.LsCommand, commands.WebCommand, commands.AliasCommand, + commands.DotfilesCommand, commands.DoctorCommand, commands.QueryCommand, } diff --git a/commands/dotfiles.go b/commands/dotfiles.go new file mode 100644 index 0000000..bed81d2 --- /dev/null +++ b/commands/dotfiles.go @@ -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 + }, +} + +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)) + 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 +} \ No newline at end of file diff --git a/model/config.go b/model/config.go index 8b159e2..cc41ae3 100644 --- a/model/config.go +++ b/model/config.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "github.com/pelletier/go-toml/v2" @@ -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() @@ -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 diff --git a/model/config_test.go b/model/config_test.go new file mode 100644 index 0000000..dfc3271 --- /dev/null +++ b/model/config_test.go @@ -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) + }) + } +} diff --git a/model/dotfile.go b/model/dotfile.go new file mode 100644 index 0000000..5d4067e --- /dev/null +++ b/model/dotfile.go @@ -0,0 +1,98 @@ +package model + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + "github.com/sirupsen/logrus" +) + +// DotfileItem represents a single dotfile to be sent to the server +type DotfileItem struct { + App string `json:"app" msgpack:"app"` + Path string `json:"path" msgpack:"path"` + Content string `json:"content" msgpack:"content"` + FileModifiedAt *time.Time `json:"fileModifiedAt" msgpack:"fileModifiedAt"` + FileType string `json:"fileType" msgpack:"fileType"` + Metadata map[string]interface{} `json:"metadata" msgpack:"metadata"` + Hostname string `json:"hostname" msgpack:"hostname"` +} + +type dotfilePushRequest struct { + Dotfiles []DotfileItem `json:"dotfiles" msgpack:"dotfiles"` +} + +type dotfileResponseItem struct { + ID int `json:"id"` + App string `json:"app"` + Path string `json:"path"` + ContentHash string `json:"contentHash"` + Size int64 `json:"size"` + FileType string `json:"fileType"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Status string `json:"status"` // "created", "existing", "error" + Error string `json:"error,omitempty"` +} + +type dotfilePushResponse struct { + Success int `json:"success"` + Failed int `json:"failed"` + Results []dotfileResponseItem `json:"results"` + UserID int `json:"userId"` +} + +// SendDotfilesToServer sends the collected dotfiles to the server +func SendDotfilesToServer(ctx context.Context, endpoint Endpoint, dotfiles []DotfileItem) (int, error) { + if len(dotfiles) == 0 { + logrus.Infoln("No dotfiles to send") + return 0, nil + } + + // Get system info for hostname + hostname, err := os.Hostname() + if err != nil { + logrus.Warnln("Failed to get hostname:", err) + hostname = "unknown" + } + + // Set hostname for all dotfiles if not already set + for i := range dotfiles { + if dotfiles[i].Hostname == "" { + dotfiles[i].Hostname = hostname + } + } + + payload := dotfilePushRequest{ + Dotfiles: dotfiles, + } + + var resp dotfilePushResponse + + err = SendHTTPRequest(HTTPRequestOptions[dotfilePushRequest, dotfilePushResponse]{ + Context: ctx, + Endpoint: endpoint, + Method: http.MethodPost, + Path: "/api/v1/dotfiles/push", + Payload: payload, + Response: &resp, + }) + if err != nil { + return 0, fmt.Errorf("failed to send dotfiles to server: %w", err) + } + + logrus.Infof("Pushed dotfiles successfully - Success: %d, Failed: %d", resp.Success, resp.Failed) + + // Log any errors + for _, result := range resp.Results { + if result.Status == "error" { + logrus.Warnf("Error pushing %s (%s): %s", result.App, result.Path, result.Error) + } + } + + return resp.UserID, nil +} \ No newline at end of file diff --git a/model/dotfile_apps.go b/model/dotfile_apps.go new file mode 100644 index 0000000..b1a9bc8 --- /dev/null +++ b/model/dotfile_apps.go @@ -0,0 +1,132 @@ +package model + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +// DotfileApp interface defines methods for handling app-specific dotfiles +type DotfileApp interface { + Name() string + GetConfigPaths() []string + CollectDotfiles(ctx context.Context) ([]DotfileItem, error) +} + +// BaseApp provides common functionality for dotfile apps +type BaseApp struct { + name string +} + +func (b *BaseApp) expandPath(path string) (string, error) { + if strings.HasPrefix(path, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, path[1:]), nil + } + return filepath.Abs(path) +} + +func (b *BaseApp) readFileContent(path string) (string, *time.Time, error) { + expandedPath, err := b.expandPath(path) + if err != nil { + return "", nil, err + } + + fileInfo, err := os.Stat(expandedPath) + if err != nil { + return "", nil, err + } + + content, err := os.ReadFile(expandedPath) + if err != nil { + return "", nil, err + } + + modTime := fileInfo.ModTime() + return string(content), &modTime, nil +} + +func (b *BaseApp) CollectFromPaths(_ context.Context, appName string, paths []string) ([]DotfileItem, error) { + hostname, _ := os.Hostname() + var dotfiles []DotfileItem + + for _, path := range paths { + expandedPath, err := b.expandPath(path) + if err != nil { + logrus.Debugf("Failed to expand path %s: %v", path, err) + continue + } + + // Check if it's a directory or file + fileInfo, err := os.Stat(expandedPath) + if err != nil { + logrus.Debugf("Path not found: %s", expandedPath) + continue + } + + if fileInfo.IsDir() { + // For directories, collect specific files + files, err := b.collectFromDirectory(expandedPath) + if err != nil { + logrus.Debugf("Failed to collect from directory %s: %v", expandedPath, err) + continue + } + + for _, file := range files { + content, modTime, err := b.readFileContent(file) + if err != nil { + logrus.Debugf("Failed to read file %s: %v", file, err) + continue + } + + dotfiles = append(dotfiles, DotfileItem{ + App: appName, + Path: file, + Content: content, + FileModifiedAt: modTime, + FileType: "file", + Hostname: hostname, + }) + } + } else { + // Single file + content, modTime, err := b.readFileContent(expandedPath) + if err != nil { + logrus.Debugf("Failed to read file %s: %v", expandedPath, err) + continue + } + + dotfiles = append(dotfiles, DotfileItem{ + App: appName, + Path: expandedPath, + Content: content, + FileModifiedAt: modTime, + FileType: "file", + Hostname: hostname, + }) + } + } + + return dotfiles, nil +} + +func (b *BaseApp) collectFromDirectory(dir string) ([]string, error) { + var files []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip files with errors + } + if !info.IsDir() && !strings.HasPrefix(info.Name(), ".") { + files = append(files, path) + } + return nil + }) + return files, err +} \ No newline at end of file diff --git a/model/dotfile_bash.go b/model/dotfile_bash.go new file mode 100644 index 0000000..a460f17 --- /dev/null +++ b/model/dotfile_bash.go @@ -0,0 +1,29 @@ +package model + +import "context" + +// BashApp handles Bash shell configuration files +type BashApp struct { + BaseApp +} + +func NewBashApp() DotfileApp { + return &BashApp{} +} + +func (b *BashApp) Name() string { + return "bash" +} + +func (b *BashApp) GetConfigPaths() []string { + return []string{ + "~/.bashrc", + "~/.bash_profile", + "~/.bash_aliases", + "~/.bash_logout", + } +} + +func (b *BashApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return b.CollectFromPaths(ctx, b.Name(), b.GetConfigPaths()) +} \ No newline at end of file diff --git a/model/dotfile_fish.go b/model/dotfile_fish.go new file mode 100644 index 0000000..5fc7573 --- /dev/null +++ b/model/dotfile_fish.go @@ -0,0 +1,28 @@ +package model + +import "context" + +// FishApp handles Fish shell configuration files +type FishApp struct { + BaseApp +} + +func NewFishApp() DotfileApp { + return &FishApp{} +} + +func (f *FishApp) Name() string { + return "fish" +} + +func (f *FishApp) GetConfigPaths() []string { + return []string{ + "~/.config/fish/config.fish", + "~/.config/fish/functions", + "~/.config/fish/conf.d", + } +} + +func (f *FishApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return f.CollectFromPaths(ctx, f.Name(), f.GetConfigPaths()) +} \ No newline at end of file diff --git a/model/dotfile_ghostty.go b/model/dotfile_ghostty.go new file mode 100644 index 0000000..1d9c26d --- /dev/null +++ b/model/dotfile_ghostty.go @@ -0,0 +1,27 @@ +package model + +import "context" + +// GhosttyApp handles Ghostty terminal configuration files +type GhosttyApp struct { + BaseApp +} + +func NewGhosttyApp() DotfileApp { + return &GhosttyApp{} +} + +func (g *GhosttyApp) Name() string { + return "ghostty" +} + +func (g *GhosttyApp) GetConfigPaths() []string { + return []string{ + "~/.config/ghostty/config", + "~/.config/ghostty", + } +} + +func (g *GhosttyApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return g.CollectFromPaths(ctx, g.Name(), g.GetConfigPaths()) +} \ No newline at end of file diff --git a/model/dotfile_git.go b/model/dotfile_git.go new file mode 100644 index 0000000..dcd7a3a --- /dev/null +++ b/model/dotfile_git.go @@ -0,0 +1,29 @@ +package model + +import "context" + +// GitApp handles Git configuration files +type GitApp struct { + BaseApp +} + +func NewGitApp() DotfileApp { + return &GitApp{} +} + +func (g *GitApp) Name() string { + return "git" +} + +func (g *GitApp) GetConfigPaths() []string { + return []string{ + "~/.gitconfig", + "~/.gitignore_global", + "~/.config/git/config", + "~/.config/git/ignore", + } +} + +func (g *GitApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return g.CollectFromPaths(ctx, g.Name(), g.GetConfigPaths()) +} \ No newline at end of file diff --git a/model/dotfile_nvim.go b/model/dotfile_nvim.go new file mode 100644 index 0000000..5522988 --- /dev/null +++ b/model/dotfile_nvim.go @@ -0,0 +1,27 @@ +package model + +import "context" + +// NvimApp handles Neovim configuration files +type NvimApp struct { + BaseApp +} + +func NewNvimApp() DotfileApp { + return &NvimApp{} +} + +func (n *NvimApp) Name() string { + return "nvim" +} + +func (n *NvimApp) GetConfigPaths() []string { + return []string{ + "~/.config/nvim", + "~/.vimrc", + } +} + +func (n *NvimApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return n.CollectFromPaths(ctx, n.Name(), n.GetConfigPaths()) +} \ No newline at end of file diff --git a/model/dotfile_zsh.go b/model/dotfile_zsh.go new file mode 100644 index 0000000..75e4789 --- /dev/null +++ b/model/dotfile_zsh.go @@ -0,0 +1,29 @@ +package model + +import "context" + +// ZshApp handles Zsh shell configuration files +type ZshApp struct { + BaseApp +} + +func NewZshApp() DotfileApp { + return &ZshApp{} +} + +func (z *ZshApp) Name() string { + return "zsh" +} + +func (z *ZshApp) GetConfigPaths() []string { + return []string{ + "~/.zshrc", + "~/.zshenv", + "~/.zprofile", + "~/.config/zsh", + } +} + +func (z *ZshApp) CollectDotfiles(ctx context.Context) ([]DotfileItem, error) { + return z.CollectFromPaths(ctx, z.Name(), z.GetConfigPaths()) +} \ No newline at end of file