diff --git a/cmd/cli/main.go b/cmd/cli/main.go index dbd2c19..87aa97a 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -31,8 +31,8 @@ func main() { Aliases: []string{"v"}, Usage: "print the version", } - configFile := os.ExpandEnv(fmt.Sprintf("%s/%s/%s", "$HOME", model.COMMAND_BASE_STORAGE_FOLDER, "config.toml")) - configService := model.NewConfigService(configFile) + configDir := os.ExpandEnv(fmt.Sprintf("%s/%s", "$HOME", model.COMMAND_BASE_STORAGE_FOLDER)) + configService := model.NewConfigService(configDir) uptraceOptions := []uptrace.Option{ uptrace.WithDSN(uptraceDsn), @@ -107,6 +107,7 @@ func main() { commands.DoctorCommand, commands.QueryCommand, commands.CCCommand, + commands.SchemaCommand, } err = app.Run(os.Args) if err != nil { diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 1dfbef2..9dec387 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -45,8 +45,8 @@ func main() { slog.SetDefault(l) ctx := context.Background() - configFile := os.ExpandEnv(fmt.Sprintf("%s/%s/%s", "$HOME", model.COMMAND_BASE_STORAGE_FOLDER, "config.toml")) - daemonConfigService := model.NewConfigService(configFile) + configDir := os.ExpandEnv(fmt.Sprintf("%s/%s", "$HOME", model.COMMAND_BASE_STORAGE_FOLDER)) + daemonConfigService := model.NewConfigService(configDir) cfg, err := daemonConfigService.ReadConfigFile(ctx) if err != nil { slog.Error("Failed to get daemon config", slog.Any("err", err)) diff --git a/commands/auth.go b/commands/auth.go index 7c6eb15..456317c 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -2,6 +2,7 @@ package commands import ( "context" + "encoding/json" "fmt" "log/slog" "os" @@ -9,11 +10,12 @@ import ( "github.com/briandowns/spinner" "github.com/gookit/color" + "github.com/invopop/jsonschema" "github.com/malamtime/cli/model" - "github.com/pelletier/go-toml/v2" "github.com/pkg/browser" "github.com/urfave/cli/v2" "go.opentelemetry.io/otel/trace" + "gopkg.in/yaml.v3" ) var AuthCommand *cli.Command = &cli.Command{ @@ -42,13 +44,22 @@ func commandAuth(c *cli.Context) error { } SetupLogger(configDir) + // Generate JSON schema for IDE autocompletion + schemaFile := configDir + "/config.schema.json" + if err := generateSchemaFile(schemaFile); err != nil { + slog.Warn("Failed to generate schema file", slog.Any("err", err)) + } + var config model.ShellTimeConfig - configFile := configDir + "/config.toml" + configFile := configDir + "/config.yaml" if _, err := os.Stat(configFile); os.IsNotExist(err) { - content, err := toml.Marshal(model.DefaultConfig) + content, err := yaml.Marshal(model.DefaultConfig) if err != nil { return fmt.Errorf("failed to marshal default config: %w", err) } + // Prepend $schema for IDE autocompletion support + schemaHeader := "# yaml-language-server: $schema=" + schemaFile + "\n" + content = append([]byte(schemaHeader), content...) err = os.WriteFile(configFile, content, 0644) if err != nil { return fmt.Errorf("failed to create config file: %w", err) @@ -73,7 +84,7 @@ func commandAuth(c *cli.Context) error { } config.Token = newToken - content, err := toml.Marshal(config) + content, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } @@ -129,3 +140,25 @@ func ApplyTokenByHandshake(_ctx context.Context, config model.ShellTimeConfig) ( time.Sleep(2 * time.Second) } } + +// generateSchemaFile generates the JSON schema file for config autocompletion +func generateSchemaFile(path string) error { + reflector := &jsonschema.Reflector{ + AllowAdditionalProperties: false, + } + + schema := reflector.Reflect(&model.ShellTimeConfig{}) + schema.Title = "ShellTime Configuration" + schema.Description = "Configuration schema for shelltime CLI. Supports both YAML (.yaml, .yml) and TOML (.toml) formats." + + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal schema: %w", err) + } + + if err := os.WriteFile(path, schemaJSON, 0644); err != nil { + return fmt.Errorf("failed to write schema file: %w", err) + } + + return nil +} diff --git a/commands/schema.go b/commands/schema.go new file mode 100644 index 0000000..3d94351 --- /dev/null +++ b/commands/schema.go @@ -0,0 +1,49 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/invopop/jsonschema" + "github.com/malamtime/cli/model" + "github.com/urfave/cli/v2" +) + +var SchemaCommand = &cli.Command{ + Name: "schema", + Usage: "Generate JSON schema for config file (for IDE autocompletion)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output file path (default: stdout)", + }, + }, + Action: func(c *cli.Context) error { + reflector := &jsonschema.Reflector{ + AllowAdditionalProperties: false, + } + + schema := reflector.Reflect(&model.ShellTimeConfig{}) + schema.Title = "ShellTime Configuration" + schema.Description = "Configuration schema for shelltime CLI. Supports both YAML (.yaml, .yml) and TOML (.toml) formats." + + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return fmt.Errorf("failed to generate schema: %w", err) + } + + outputPath := c.String("output") + if outputPath != "" { + if err := os.WriteFile(outputPath, schemaJSON, 0644); err != nil { + return fmt.Errorf("failed to write schema file: %w", err) + } + fmt.Printf("Schema written to %s\n", outputPath) + } else { + fmt.Println(string(schemaJSON)) + } + + return nil + }, +} diff --git a/go.mod b/go.mod index a3b84f1..96f3643 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-git/go-git/v5 v5.16.4 github.com/google/uuid v1.6.0 github.com/gookit/color v1.6.0 + github.com/invopop/jsonschema v0.13.0 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/olekukonko/tablewriter v1.1.2 github.com/pelletier/go-toml/v2 v2.2.4 @@ -24,12 +25,15 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 go.opentelemetry.io/proto/otlp v1.9.0 google.golang.org/grpc v1.77.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.6.1 // indirect @@ -50,6 +54,7 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect @@ -61,6 +66,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -83,5 +89,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a548548..756263c 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,12 @@ github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQA github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -88,8 +92,11 @@ github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -108,6 +115,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -168,6 +177,8 @@ github.com/uptrace/uptrace-go v1.39.0 h1:MszuE3eX/z86xzYywN2JBtYcmsS4ofdo1VMDhRv github.com/uptrace/uptrace-go v1.39.0/go.mod h1:FquipEqgTMXPbhdhenjbiLHG1R5WYdxVH6zgwHeMzzA= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= diff --git a/model/config.go b/model/config.go index bc0c837..c8b9428 100644 --- a/model/config.go +++ b/model/config.go @@ -9,8 +9,76 @@ import ( "sync" "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" ) +// configFormat represents the format of a config file +type configFormat string + +const ( + formatTOML configFormat = "toml" + formatYAML configFormat = "yaml" +) + +// detectFormat returns the format based on file extension +func detectFormat(filename string) configFormat { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".yaml", ".yml": + return formatYAML + default: + return formatTOML + } +} + +// unmarshalConfig unmarshals config data based on format +func unmarshalConfig(data []byte, format configFormat, config *ShellTimeConfig) error { + switch format { + case formatYAML: + return yaml.Unmarshal(data, config) + default: + return toml.Unmarshal(data, config) + } +} + +// configFiles represents discovered config files +type configFiles struct { + baseFile string // config.yaml, config.yml, or config.toml + localFile string // config.local.yaml, config.local.yml, or config.local.toml + baseFormat configFormat + localFormat configFormat +} + +// findConfigFiles discovers config files in priority order +// Priority: config.local.yaml > config.local.yml > config.yaml > config.yml > config.local.toml > config.toml +func findConfigFiles(configDir string) configFiles { + result := configFiles{} + + // Check for base config files (YAML first, then TOML) + baseFiles := []string{"config.yaml", "config.yml", "config.toml"} + for _, name := range baseFiles { + path := filepath.Join(configDir, name) + if _, err := os.Stat(path); err == nil { + result.baseFile = path + result.baseFormat = detectFormat(name) + break + } + } + + // Check for local config files (YAML first, then TOML) + localFiles := []string{"config.local.yaml", "config.local.yml", "config.local.toml"} + for _, name := range localFiles { + path := filepath.Join(configDir, name) + if _, err := os.Stat(path); err == nil { + result.localFile = path + result.localFormat = detectFormat(name) + break + } + } + + return result +} + var UserShellTimeConfig ShellTimeConfig type ConfigService interface { @@ -33,14 +101,17 @@ func WithSkipCache() ReadConfigOption { } type configService struct { - configFilePath string - cachedConfig *ShellTimeConfig - mu sync.RWMutex + configDir string + cachedConfig *ShellTimeConfig + mu sync.RWMutex } -func NewConfigService(configFilePath string) ConfigService { +// NewConfigService creates a new ConfigService that reads config files from the given directory. +// It supports both YAML (.yaml, .yml) and TOML (.toml) formats with priority: +// config.local.yaml > config.local.yml > config.yaml > config.yml > config.local.toml > config.toml +func NewConfigService(configDir string) ConfigService { return &configService{ - configFilePath: configFilePath, + configDir: configDir, } } @@ -121,32 +192,32 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO cs.mu.RUnlock() } - configFile := cs.configFilePath - existingConfig, err := os.ReadFile(configFile) + // Discover config files with priority + files := findConfigFiles(cs.configDir) + + // Read base config file + if files.baseFile == "" { + err = fmt.Errorf("no config file found in %s", cs.configDir) + return + } + + existingConfig, err := os.ReadFile(files.baseFile) if err != nil { err = fmt.Errorf("failed to read config file: %w", err) return } - err = toml.Unmarshal(existingConfig, &config) + err = unmarshalConfig(existingConfig, files.baseFormat, &config) if err != nil { err = fmt.Errorf("failed to parse config file: %w", err) 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 + // Read and merge local config if exists + if files.localFile != "" { + if localConfig, localErr := os.ReadFile(files.localFile); localErr == nil { var localSettings ShellTimeConfig - if unmarshalErr := toml.Unmarshal(localConfig, &localSettings); unmarshalErr == nil { - // Merge local settings into base config + if unmarshalErr := unmarshalConfig(localConfig, files.localFormat, &localSettings); unmarshalErr == nil { mergeConfig(&config, &localSettings) } } diff --git a/model/config_test.go b/model/config_test.go index 38df2e3..7bfc792 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -18,11 +18,11 @@ func TestReadConfigFileWithLocal(t *testing.T) { // 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 + baseConfig := `token = 'base-token' +apiEndpoint = 'https://api.base.com' +webEndpoint = 'https://base.com' +flushCount = 5 +gcTime = 7 dataMasking = false enableMetrics = false encrypted = false` @@ -31,15 +31,15 @@ encrypted = false` // 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 + 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) + // Test reading config with local override (now uses directory path) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -64,16 +64,16 @@ func TestReadConfigFileWithoutLocal(t *testing.T) { // 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` + 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) + // Test reading config without local file (now uses directory path) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -85,62 +85,206 @@ GCTime = 7` 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", - }, - } +func TestReadYAMLConfig(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) - 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 YAML config file + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: yaml-token +apiEndpoint: https://api.yaml.com +webEndpoint: https://yaml.com +flushCount: 15 +gcTime: 21` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) - // 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) + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + 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) + assert.Equal(t, "yaml-token", config.Token) + assert.Equal(t, "https://api.yaml.com", config.APIEndpoint) + assert.Equal(t, "https://yaml.com", config.WebEndpoint) + assert.Equal(t, 15, config.FlushCount) + assert.Equal(t, 21, config.GCTime) +} - // Test reading config with local override - cs := NewConfigService(baseConfigPath) - config, err := cs.ReadConfigFile(context.Background()) - require.NoError(t, err) +func TestReadYMLConfig(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) - // 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) - }) - } + // Create YML config file + ymlConfigPath := filepath.Join(tmpDir, "config.yml") + ymlConfig := `token: yml-token +apiEndpoint: https://api.yml.com +flushCount: 12` + err = os.WriteFile(ymlConfigPath, []byte(ymlConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "yml-token", config.Token) + assert.Equal(t, "https://api.yml.com", config.APIEndpoint) + assert.Equal(t, 12, config.FlushCount) +} + +func TestYAMLConfigWithLocalOverride(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 YAML config file + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: base-yaml-token +apiEndpoint: https://api.base.com +flushCount: 5` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) + + // Create local YAML config file + localYamlConfigPath := filepath.Join(tmpDir, "config.local.yaml") + localYamlConfig := `token: local-yaml-token +flushCount: 20` + err = os.WriteFile(localYamlConfigPath, []byte(localYamlConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Verify local config overrides base config + assert.Equal(t, "local-yaml-token", config.Token) + assert.Equal(t, 20, config.FlushCount) + // Base value should be kept + assert.Equal(t, "https://api.base.com", config.APIEndpoint) +} + +func TestConfigPriority_YAMLOverTOML(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 both TOML and YAML config files + tomlConfigPath := filepath.Join(tmpDir, "config.toml") + tomlConfig := `token = 'toml-token' +apiEndpoint = 'https://api.toml.com'` + err = os.WriteFile(tomlConfigPath, []byte(tomlConfig), 0644) + require.NoError(t, err) + + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: yaml-token +apiEndpoint: https://api.yaml.com` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // YAML should take priority over TOML + assert.Equal(t, "yaml-token", config.Token) + assert.Equal(t, "https://api.yaml.com", config.APIEndpoint) +} + +func TestConfigPriority_YAMLOverYML(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 both .yaml and .yml config files + ymlConfigPath := filepath.Join(tmpDir, "config.yml") + ymlConfig := `token: yml-token` + err = os.WriteFile(ymlConfigPath, []byte(ymlConfig), 0644) + require.NoError(t, err) + + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: yaml-token` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // .yaml should take priority over .yml + assert.Equal(t, "yaml-token", config.Token) +} + +func TestConfigPriority_LocalYAMLOverLocalTOML(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 + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: base-token +flushCount: 5` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) + + // Create both local TOML and local YAML + localTomlPath := filepath.Join(tmpDir, "config.local.toml") + localToml := `token = 'local-toml-token' +flushCount = 10` + err = os.WriteFile(localTomlPath, []byte(localToml), 0644) + require.NoError(t, err) + + localYamlPath := filepath.Join(tmpDir, "config.local.yaml") + localYaml := `token: local-yaml-token +flushCount: 20` + err = os.WriteFile(localYamlPath, []byte(localYaml), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // local.yaml should take priority over local.toml + assert.Equal(t, "local-yaml-token", config.Token) + assert.Equal(t, 20, config.FlushCount) +} + +func TestCrossFormatConfig_YAMLBaseWithTOMLLocal(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 YAML base config + yamlConfigPath := filepath.Join(tmpDir, "config.yaml") + yamlConfig := `token: yaml-base-token +apiEndpoint: https://api.yaml.com +flushCount: 5` + err = os.WriteFile(yamlConfigPath, []byte(yamlConfig), 0644) + require.NoError(t, err) + + // Create TOML local config (no YAML local exists) + localTomlPath := filepath.Join(tmpDir, "config.local.toml") + localToml := `token = 'toml-local-token' +flushCount = 15` + err = os.WriteFile(localTomlPath, []byte(localToml), 0644) + require.NoError(t, err) + + cs := NewConfigService(tmpDir) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Local TOML should override YAML base + assert.Equal(t, "toml-local-token", config.Token) + assert.Equal(t, 15, config.FlushCount) + // Base value should be kept + assert.Equal(t, "https://api.yaml.com", config.APIEndpoint) } func TestLogCleanupDefaults(t *testing.T) { @@ -151,12 +295,12 @@ func TestLogCleanupDefaults(t *testing.T) { // Create config file without LogCleanup section baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'test-token' -APIEndpoint = 'https://api.test.com'` + baseConfig := `token = 'test-token' +apiEndpoint = 'https://api.test.com'` err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -174,8 +318,8 @@ func TestLogCleanupCustomValues(t *testing.T) { // Create config file with custom LogCleanup settings baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'test-token' -APIEndpoint = 'https://api.test.com' + baseConfig := `token = 'test-token' +apiEndpoint = 'https://api.test.com' [logCleanup] enabled = false @@ -183,7 +327,7 @@ thresholdMB = 200` err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -202,7 +346,7 @@ func TestLogCleanupPartialConfig(t *testing.T) { }{ { name: "Only enabled set to false", - config: `Token = 'test-token' + config: `token = 'test-token' [logCleanup] enabled = false`, expectedEnabled: false, @@ -210,7 +354,7 @@ enabled = false`, }, { name: "Only threshold set", - config: `Token = 'test-token' + config: `token = 'test-token' [logCleanup] thresholdMB = 50`, expectedEnabled: true, // default @@ -228,7 +372,7 @@ thresholdMB = 50`, err = os.WriteFile(baseConfigPath, []byte(tc.config), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -247,7 +391,7 @@ func TestLogCleanupMergeFromLocal(t *testing.T) { // Create base config file with LogCleanup baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'base-token' + baseConfig := `token = 'base-token' [logCleanup] enabled = true thresholdMB = 100` @@ -262,7 +406,7 @@ thresholdMB = 500` err = os.WriteFile(localConfigPath, []byte(localConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -280,7 +424,7 @@ func TestCodeTrackingMergeFromLocal(t *testing.T) { // Create base config file with CodeTracking disabled baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'base-token' + baseConfig := `token = 'base-token' [codeTracking] enabled = false` err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) @@ -293,7 +437,7 @@ enabled = true` err = os.WriteFile(localConfigPath, []byte(localConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -310,8 +454,8 @@ func TestCodeTrackingCustomEndpointAndToken(t *testing.T) { // Create config file with custom CodeTracking endpoint and token baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'global-token' -APIEndpoint = 'https://api.global.com' + baseConfig := `token = 'global-token' +apiEndpoint = 'https://api.global.com' [codeTracking] enabled = true @@ -320,7 +464,7 @@ token = 'custom-heartbeat-token'` err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -344,8 +488,8 @@ func TestCodeTrackingPartialCustomConfig(t *testing.T) { }{ { name: "Only custom apiEndpoint", - config: `Token = 'global-token' -APIEndpoint = 'https://api.global.com' + config: `token = 'global-token' +apiEndpoint = 'https://api.global.com' [codeTracking] enabled = true @@ -355,8 +499,8 @@ apiEndpoint = 'https://api.custom.com'`, }, { name: "Only custom token", - config: `Token = 'global-token' -APIEndpoint = 'https://api.global.com' + config: `token = 'global-token' +apiEndpoint = 'https://api.global.com' [codeTracking] enabled = true @@ -366,8 +510,8 @@ token = 'custom-token'`, }, { name: "No custom endpoint or token", - config: `Token = 'global-token' -APIEndpoint = 'https://api.global.com' + config: `token = 'global-token' +apiEndpoint = 'https://api.global.com' [codeTracking] enabled = true`, @@ -386,7 +530,7 @@ enabled = true`, err = os.WriteFile(baseConfigPath, []byte(tc.config), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) @@ -405,8 +549,8 @@ func TestCodeTrackingMergeEndpointFromLocal(t *testing.T) { // Create base config file with CodeTracking baseConfigPath := filepath.Join(tmpDir, "config.toml") - baseConfig := `Token = 'base-token' -APIEndpoint = 'https://api.base.com' + baseConfig := `token = 'base-token' +apiEndpoint = 'https://api.base.com' [codeTracking] enabled = true @@ -424,7 +568,7 @@ token = 'local-heartbeat-token'` err = os.WriteFile(localConfigPath, []byte(localConfig), 0644) require.NoError(t, err) - cs := NewConfigService(baseConfigPath) + cs := NewConfigService(tmpDir) config, err := cs.ReadConfigFile(context.Background()) require.NoError(t, err) diff --git a/model/path.go b/model/path.go index e797d4c..938beaf 100644 --- a/model/path.go +++ b/model/path.go @@ -22,16 +22,36 @@ func GetStoragePath(subpaths ...string) string { return filepath.Join(append([]string{GetBaseStoragePath()}, subpaths...)...) } -// GetConfigFilePath returns the path to the main config file +// GetConfigFilePath returns the path to the main config file (TOML) func GetConfigFilePath() string { return GetStoragePath("config.toml") } -// GetLocalConfigFilePath returns the path to the local config file +// GetLocalConfigFilePath returns the path to the local config file (TOML) func GetLocalConfigFilePath() string { return GetStoragePath("config.local.toml") } +// GetYAMLConfigFilePath returns the path to the main config file (YAML) +func GetYAMLConfigFilePath() string { + return GetStoragePath("config.yaml") +} + +// GetYMLConfigFilePath returns the path to the main config file (YML) +func GetYMLConfigFilePath() string { + return GetStoragePath("config.yml") +} + +// GetLocalYAMLConfigFilePath returns the path to the local config file (YAML) +func GetLocalYAMLConfigFilePath() string { + return GetStoragePath("config.local.yaml") +} + +// GetLocalYMLConfigFilePath returns the path to the local config file (YML) +func GetLocalYMLConfigFilePath() string { + return GetStoragePath("config.local.yml") +} + // GetLogFilePath returns the path to the log file func GetLogFilePath() string { return GetStoragePath("log.log") diff --git a/model/types.go b/model/types.go index 53c3c6f..46719c5 100644 --- a/model/types.go +++ b/model/types.go @@ -5,93 +5,93 @@ const ( ) type Endpoint struct { - APIEndpoint string `toml:"apiEndpoint"` - Token string `token:"token"` + APIEndpoint string `toml:"APIEndpoint" yaml:"apiEndpoint" json:"apiEndpoint"` + Token string `toml:"Token" yaml:"token" json:"token"` } type AIAgentConfig struct { // AutoRun settings for different command types - View bool `toml:"view"` - Edit bool `toml:"edit"` - Delete bool `toml:"delete"` + View bool `toml:"view" yaml:"view" json:"view"` + Edit bool `toml:"edit" yaml:"edit" json:"edit"` + Delete bool `toml:"delete" yaml:"delete" json:"delete"` } type AIConfig struct { - Agent AIAgentConfig `toml:"agent"` - ShowTips *bool `toml:"showTips"` + Agent AIAgentConfig `toml:"agent" yaml:"agent" json:"agent"` + ShowTips *bool `toml:"showTips" yaml:"showTips" json:"showTips"` } type CCUsage struct { - Enabled *bool `toml:"enabled"` + Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` } // CCOtel configuration for OTEL-based Claude Code tracking (v2) type CCOtel struct { - Enabled *bool `toml:"enabled"` - GRPCPort int `toml:"grpcPort"` // default: 4317 - Debug *bool `toml:"debug"` // write raw JSON to debug files + Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` + GRPCPort int `toml:"grpcPort" yaml:"grpcPort" json:"grpcPort"` // default: 4317 + Debug *bool `toml:"debug" yaml:"debug" json:"debug"` // write raw JSON to debug files } // CodeTracking configuration for coding activity heartbeat tracking type CodeTracking struct { - Enabled *bool `toml:"enabled"` - APIEndpoint string `toml:"apiEndpoint"` // Custom API endpoint for heartbeats - Token string `toml:"token"` // Custom token for heartbeats + Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` + APIEndpoint string `toml:"apiEndpoint" yaml:"apiEndpoint" json:"apiEndpoint"` // Custom API endpoint for heartbeats + Token string `toml:"token" yaml:"token" json:"token"` // Custom token for heartbeats } // LogCleanup configuration for automatic log file cleanup type LogCleanup struct { - Enabled *bool `toml:"enabled"` // default: true (enabled by default) - ThresholdMB int64 `toml:"thresholdMB"` // default: 100 MB + Enabled *bool `toml:"enabled" yaml:"enabled" json:"enabled"` // default: true (enabled by default) + ThresholdMB int64 `toml:"thresholdMB" yaml:"thresholdMB" json:"thresholdMB"` // default: 100 MB } type ShellTimeConfig struct { - Token string - APIEndpoint string - WebEndpoint string + Token string `toml:"Token" yaml:"token" json:"token"` + APIEndpoint string `toml:"APIEndpoint" yaml:"apiEndpoint" json:"apiEndpoint"` + WebEndpoint string `toml:"WebEndpoint" yaml:"webEndpoint" json:"webEndpoint"` // how often sync to server - FlushCount int + FlushCount int `toml:"FlushCount" yaml:"flushCount" json:"flushCount"` // how long the synced data would keep in db: // unit is days - GCTime int + GCTime int `toml:"GCTime" yaml:"gcTime" json:"gcTime"` // is data should be masking? // @default true - DataMasking *bool `toml:"dataMasking"` + DataMasking *bool `toml:"dataMasking" yaml:"dataMasking" json:"dataMasking"` // for debug purpose - Endpoints []Endpoint `toml:"ENDPOINTS"` + Endpoints []Endpoint `toml:"ENDPOINTS" yaml:"endpoints" json:"endpoints"` // WARNING // This config will track each command metrics you run in current shell. // Use this config only the developer asked you to do so. // This could be very slow on each command you run. - EnableMetrics *bool `toml:"enableMetrics"` + EnableMetrics *bool `toml:"enableMetrics" yaml:"enableMetrics" json:"enableMetrics"` - Encrypted *bool `toml:"encrypted"` + Encrypted *bool `toml:"encrypted" yaml:"encrypted" json:"encrypted"` // AI configuration - AI *AIConfig `toml:"ai"` + AI *AIConfig `toml:"ai" yaml:"ai" json:"ai"` // Exclude patterns - regular expressions to exclude commands from being saved // Commands matching any of these patterns will not be synced to the server - Exclude []string `toml:"exclude"` + Exclude []string `toml:"exclude" yaml:"exclude" json:"exclude"` // CCUsage configuration for Claude Code usage tracking (v1 - ccusage CLI based) - CCUsage *CCUsage `toml:"ccusage"` + CCUsage *CCUsage `toml:"ccusage" yaml:"ccusage" json:"ccusage"` // CCOtel configuration for OTEL-based Claude Code tracking (v2 - gRPC passthrough) - CCOtel *CCOtel `toml:"ccotel"` + CCOtel *CCOtel `toml:"ccotel" yaml:"ccotel" json:"ccotel"` // CodeTracking configuration for coding activity heartbeat tracking - CodeTracking *CodeTracking `toml:"codeTracking"` + CodeTracking *CodeTracking `toml:"codeTracking" yaml:"codeTracking" json:"codeTracking"` // LogCleanup configuration for automatic log file cleanup in daemon - LogCleanup *LogCleanup `toml:"logCleanup"` + LogCleanup *LogCleanup `toml:"logCleanup" yaml:"logCleanup" json:"logCleanup"` // SocketPath is the path to the Unix domain socket used for communication // between the CLI and the daemon. - SocketPath string `toml:"socketPath"` + SocketPath string `toml:"socketPath" yaml:"socketPath" json:"socketPath"` } var DefaultAIConfig = &AIConfig{ @@ -116,10 +116,10 @@ var DefaultConfig = ShellTimeConfig{ Encrypted: nil, AI: DefaultAIConfig, Exclude: []string{}, - CCUsage: nil, - CCOtel: nil, - CodeTracking: nil, - LogCleanup: nil, + CCUsage: nil, + CCOtel: nil, + CodeTracking: nil, + LogCleanup: nil, SocketPath: DefaultSocketPath, }