Skip to content

Commit 64959db

Browse files
committed
fix: address phase1 cli review feedback
1 parent 8c47148 commit 64959db

9 files changed

Lines changed: 281 additions & 29 deletions

File tree

cmd/collect/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,19 @@ only `api_url` + `api_key` into `~/.wakatime.cfg`, preserving existing plugin
5151
settings such as `debug`, `include`, and `exclude`. It also honors
5252
`STINT_API_URL` and `STINT_API_KEY` for scripted installs.
5353

54+
For WakaTime-compatible `stint` commands such as heartbeats, stats, and editor
55+
plugin calls, credentials are resolved as: explicit flags, `STINT_*` env,
56+
`~/.stint.cfg`, `~/.wakatime.cfg`, then built-in defaults. `WAKATIME_API_KEY`
57+
is accepted as a later API-key fallback for compatibility.
58+
5459
## Configuration
5560

5661
Collector settings are resolved from these layers, **highest precedence first**:
5762

5863
1. **Explicit command-line flags** (e.g. `--api-url`)
5964
2. **Environment variables** (`STINT_API_URL`, ...)
6065
3. **Collector config file** (`~/.stint/collect.json`, override with `--config PATH`)
61-
4. **Native Stint config** (`~/.stint.cfg`) and WakaTime plugin config (`~/.wakatime.cfg`) for API credentials
62-
5. **Built-in defaults**
66+
4. **Built-in defaults**
6367

6468
A flag only overrides lower layers when you actually pass it; a flag left at its
6569
default does not clobber an env var or config file value.

internal/stintcli/cli.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
5757
return runHeartbeatsList(args[1:], stdout)
5858
case "config":
5959
return runConfig(args[1:], stdout)
60-
case "setup":
61-
return runSetup(args[1:], stdout)
62-
case "cli":
63-
return runCLICommand(args[1:], stdout)
6460
case "today":
6561
return runToday(args[1:], stdout)
6662
case "today-goal":

internal/stintcli/cobra.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@ func newCobraRoot(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
2828
Use: "setup",
2929
Short: "Write Stint and WakaTime-compatible config",
3030
RunE: func(cmd *cobra.Command, args []string) error {
31-
return runSetup(append(cobraFlags(cmd), args...), stdout)
31+
if commandWantsHelp(args) {
32+
return cmd.Help()
33+
}
34+
return runSetup(args, stdout)
3235
},
3336
DisableFlagParsing: true,
3437
})
3538
root.AddCommand(&cobra.Command{
3639
Use: "collect",
3740
Short: "Scan local AI agent data and post usage events",
3841
RunE: func(cmd *cobra.Command, args []string) error {
42+
if commandWantsHelp(args) {
43+
return cmd.Help()
44+
}
3945
return runCollect(args, stdin, stdout, stderr)
4046
},
4147
DisableFlagParsing: true,
@@ -45,6 +51,9 @@ func newCobraRoot(stdin io.Reader, stdout, stderr io.Writer) *cobra.Command {
4551
Use: "install",
4652
Short: "Install the pinned upstream wakatime-cli",
4753
RunE: func(cmd *cobra.Command, args []string) error {
54+
if commandWantsHelp(args) {
55+
return cmd.Help()
56+
}
4857
return runWakaTimeCLIInstall(args, stdout)
4958
},
5059
DisableFlagParsing: true,
@@ -62,9 +71,11 @@ func cobraManagedCommand(command string) bool {
6271
}
6372
}
6473

65-
func cobraFlags(cmd *cobra.Command) []string {
66-
if cmd == nil {
67-
return nil
74+
func commandWantsHelp(args []string) bool {
75+
for _, arg := range args {
76+
if arg == "--help" || arg == "-h" {
77+
return true
78+
}
6879
}
69-
return cmd.Flags().Args()
80+
return false
7081
}

internal/stintcli/cobra_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package stintcli
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
_ "modernc.org/sqlite"
9+
)
10+
11+
func TestCobraManagedCommandsShowHelp(t *testing.T) {
12+
for _, args := range [][]string{
13+
{"setup", "--help"},
14+
{"collect", "--help"},
15+
{"cli", "install", "--help"},
16+
} {
17+
t.Run(strings.Join(args, " "), func(t *testing.T) {
18+
var out bytes.Buffer
19+
if err := Run(args, nil, &out, &bytes.Buffer{}); err != nil {
20+
t.Fatal(err)
21+
}
22+
if !strings.Contains(out.String(), "Usage:") {
23+
t.Fatalf("expected help output, got %q", out.String())
24+
}
25+
})
26+
}
27+
}

internal/stintcli/options.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,10 @@ func parseCommonWithFlagSet(fs *flag.FlagSet, args []string) (Options, error) {
356356
if err != nil {
357357
return o, err
358358
}
359-
nativeCfg := loadNativeConfig()
359+
nativeCfg, err := loadNativeConfig()
360+
if err != nil {
361+
return o, fmt.Errorf("load native config: %w", err)
362+
}
360363
o.Config = cfg
361364
internalCfg, _ := LoadConfig(o.InternalConfigPath)
362365
o.InternalConfig = internalCfg
@@ -724,16 +727,24 @@ func resolveAPIKeyFromConfigs(key string, configs []Config, fallbacks ...string)
724727
if key != "" {
725728
return key, nil
726729
}
730+
var vaultErr error
727731
for _, cfg := range configs {
728732
apiKey, err := resolveAPIKeyFromConfig(cfg)
729733
if err != nil {
730-
return "", err
734+
vaultErr = err
735+
continue
731736
}
732737
if apiKey != "" {
733738
return apiKey, nil
734739
}
735740
}
736-
return first(fallbacks...), nil
741+
if fallback := first(fallbacks...); fallback != "" {
742+
return fallback, nil
743+
}
744+
if vaultErr != nil {
745+
return "", vaultErr
746+
}
747+
return "", nil
737748
}
738749

739750
func resolveAPIKeyFromConfig(cfg Config, fallbacks ...string) (string, error) {

internal/stintcli/setup_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,87 @@ func TestParseCommonUsesEnvBeforeStintConfig(t *testing.T) {
101101
}
102102
}
103103

104+
func TestParseCommonErrorsWhenNativeConfigCannotBeRead(t *testing.T) {
105+
if os.Getuid() == 0 {
106+
t.Skip("permission fixture is not meaningful as root")
107+
}
108+
home := t.TempDir()
109+
t.Setenv("HOME", home)
110+
t.Setenv("WAKATIME_HOME", home)
111+
if err := os.WriteFile(DefaultStintConfigPath(), []byte("[settings]\napi_key = waka_native\n"), 0o000); err != nil {
112+
t.Fatal(err)
113+
}
114+
t.Cleanup(func() { _ = os.Chmod(DefaultStintConfigPath(), 0o600) })
115+
116+
_, err := parseCommon(nil)
117+
if err == nil || !strings.Contains(err.Error(), "load native config") {
118+
t.Fatalf("expected native config load error, got %v", err)
119+
}
120+
}
121+
122+
func TestParseCommonFallsBackToWakaTimeConfigWhenNativeVaultFails(t *testing.T) {
123+
home := t.TempDir()
124+
t.Setenv("HOME", home)
125+
t.Setenv("WAKATIME_HOME", home)
126+
127+
native := Config{Sections: map[string]map[string]string{}}
128+
native.Set("settings", "api_key_vault_cmd", "exit 9")
129+
if err := native.Write(DefaultStintConfigPath()); err != nil {
130+
t.Fatal(err)
131+
}
132+
waka := Config{Sections: map[string]map[string]string{}}
133+
waka.Set("settings", "api_key", "waka_fallback")
134+
if err := waka.Write(DefaultWakaTimeConfigPath()); err != nil {
135+
t.Fatal(err)
136+
}
137+
138+
opts, err := parseCommon(nil)
139+
if err != nil {
140+
t.Fatal(err)
141+
}
142+
if opts.APIKey != "waka_fallback" {
143+
t.Fatalf("expected wakatime fallback key, got %q", opts.APIKey)
144+
}
145+
}
146+
147+
func TestSetupDoesNotUpdateNativeConfigWhenWakaTimeWriteCannotBePrepared(t *testing.T) {
148+
if os.Getuid() == 0 {
149+
t.Skip("permission fixture is not meaningful as root")
150+
}
151+
home := t.TempDir()
152+
t.Setenv("HOME", home)
153+
t.Setenv("WAKATIME_HOME", home)
154+
155+
original := Config{Sections: map[string]map[string]string{}}
156+
original.Set("settings", "api_url", "https://old.example.com/api/v1")
157+
original.Set("settings", "api_key", "waka_old")
158+
if err := original.Write(DefaultStintConfigPath()); err != nil {
159+
t.Fatal(err)
160+
}
161+
blockedDir := filepath.Join(home, "blocked")
162+
if err := os.Mkdir(blockedDir, 0o500); err != nil {
163+
t.Fatal(err)
164+
}
165+
t.Cleanup(func() { _ = os.Chmod(blockedDir, 0o700) })
166+
167+
err := Run([]string{
168+
"setup",
169+
"--server", "https://new.example.com/api/v1",
170+
"--key", "waka_new",
171+
"--wakatime-config", filepath.Join(blockedDir, ".wakatime.cfg"),
172+
}, nil, &bytes.Buffer{}, &bytes.Buffer{})
173+
if err == nil {
174+
t.Fatal("expected wakatime config write error")
175+
}
176+
cfg, loadErr := LoadConfig(DefaultStintConfigPath())
177+
if loadErr != nil {
178+
t.Fatal(loadErr)
179+
}
180+
if cfg.Get("settings", "api_url") != "https://old.example.com/api/v1" || cfg.Get("settings", "api_key") != "waka_old" {
181+
t.Fatalf("native config was partially updated: %#v", cfg.Section("settings"))
182+
}
183+
}
184+
104185
func TestSetupHonorsEnvironmentFallbacks(t *testing.T) {
105186
home := t.TempDir()
106187
t.Setenv("HOME", home)

internal/stintcli/stint_config.go

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,34 +27,115 @@ func runSetup(args []string, stdout io.Writer) error {
2727
if apiURL == "" || apiKey == "" {
2828
return fmt.Errorf("server and key are required (use --server/--key or STINT_API_URL/STINT_API_KEY)")
2929
}
30-
if err := writeSetupConfig(*stintConfig, apiURL, apiKey, true); err != nil {
31-
return fmt.Errorf("write stint config: %w", err)
30+
stintWrite, err := prepareSetupConfig(*stintConfig, apiURL, apiKey, true)
31+
if err != nil {
32+
return fmt.Errorf("prepare stint config: %w", err)
3233
}
33-
if err := writeSetupConfig(*wakaConfig, apiURL, apiKey, false); err != nil {
34-
return fmt.Errorf("write wakatime config: %w", err)
34+
defer stintWrite.cleanup()
35+
wakaWrite, err := prepareSetupConfig(*wakaConfig, apiURL, apiKey, false)
36+
if err != nil {
37+
return fmt.Errorf("prepare wakatime config: %w", err)
38+
}
39+
defer wakaWrite.cleanup()
40+
if err := commitSetupConfigs(stintWrite, wakaWrite); err != nil {
41+
return err
3542
}
3643
fmt.Fprintf(stdout, "wrote %s and %s\n", expandHome(*stintConfig), expandHome(*wakaConfig))
3744
return nil
3845
}
3946

4047
func writeSetupConfig(path, apiURL, apiKey string, native bool) error {
48+
write, err := prepareSetupConfig(path, apiURL, apiKey, native)
49+
if err != nil {
50+
return err
51+
}
52+
defer write.cleanup()
53+
return write.commit()
54+
}
55+
56+
type preparedSetupConfig struct {
57+
path string
58+
tmpPath string
59+
oldBytes []byte
60+
hadOld bool
61+
committed bool
62+
}
63+
64+
func prepareSetupConfig(path, apiURL, apiKey string, native bool) (preparedSetupConfig, error) {
4165
path = expandHome(path)
4266
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
43-
return err
67+
return preparedSetupConfig{}, err
4468
}
4569
cfg, err := LoadConfig(path)
4670
if err != nil {
47-
return err
71+
return preparedSetupConfig{}, err
4872
}
4973
cfg.Set("settings", "api_url", apiURL)
5074
cfg.Set("settings", "api_key", apiKey)
5175
if native {
5276
cfg.Set("settings", "offline", "true")
5377
}
54-
return cfg.Write(path)
78+
prepared := preparedSetupConfig{path: path}
79+
if oldBytes, err := os.ReadFile(path); err == nil {
80+
prepared.oldBytes = oldBytes
81+
prepared.hadOld = true
82+
} else if !os.IsNotExist(err) {
83+
return preparedSetupConfig{}, err
84+
}
85+
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
86+
if err != nil {
87+
return preparedSetupConfig{}, err
88+
}
89+
prepared.tmpPath = tmp.Name()
90+
if err := tmp.Close(); err != nil {
91+
prepared.cleanup()
92+
return preparedSetupConfig{}, err
93+
}
94+
if err := cfg.Write(prepared.tmpPath); err != nil {
95+
prepared.cleanup()
96+
return preparedSetupConfig{}, err
97+
}
98+
return prepared, nil
99+
}
100+
101+
func commitSetupConfigs(configs ...preparedSetupConfig) error {
102+
var committed []preparedSetupConfig
103+
for _, config := range configs {
104+
if err := config.commit(); err != nil {
105+
for i := len(committed) - 1; i >= 0; i-- {
106+
_ = committed[i].restore()
107+
}
108+
return err
109+
}
110+
committed = append(committed, config)
111+
}
112+
return nil
113+
}
114+
115+
func (p *preparedSetupConfig) commit() error {
116+
if err := os.Rename(p.tmpPath, p.path); err != nil {
117+
return err
118+
}
119+
p.committed = true
120+
return nil
121+
}
122+
123+
func (p preparedSetupConfig) restore() error {
124+
if p.hadOld {
125+
return os.WriteFile(p.path, p.oldBytes, 0o600)
126+
}
127+
if err := os.Remove(p.path); err != nil && !os.IsNotExist(err) {
128+
return err
129+
}
130+
return nil
131+
}
132+
133+
func (p preparedSetupConfig) cleanup() {
134+
if !p.committed && p.tmpPath != "" {
135+
_ = os.Remove(p.tmpPath)
136+
}
55137
}
56138

57-
func loadNativeConfig() Config {
58-
cfg, _ := LoadConfig(DefaultStintConfigPath())
59-
return cfg
139+
func loadNativeConfig() (Config, error) {
140+
return LoadConfig(DefaultStintConfigPath())
60141
}

0 commit comments

Comments
 (0)