Skip to content

Commit 9a033b8

Browse files
committed
fix conflicts
2 parents bba8f10 + 8e255d8 commit 9a033b8

27 files changed

Lines changed: 892 additions & 1001 deletions

bin/install-latest-linux.sh

Lines changed: 0 additions & 36 deletions
This file was deleted.

bin/install-latest.sh

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ case "${ARCH}" in
99
aarch64) ARCH="arm64" ;;
1010
esac
1111

12-
# Get the appropriate download URL for this platform
13-
DOWNLOAD_URL="$(curl -s https://api.github.com/repos/brevdev/brev-cli/releases/latest | grep "browser_download_url.*${OS}.*${ARCH}" | cut -d '"' -f 4)"
12+
# Fetch release metadata from GitHub API
13+
API_RESPONSE="$(curl -sf ${GITHUB_TOKEN:+-H "Authorization: token ${GITHUB_TOKEN}"} https://api.github.com/repos/brevdev/brev-cli/releases/latest)" || {
14+
echo "Error: Failed to fetch release info from GitHub API." >&2
15+
echo "This is often caused by rate limiting when many requests come from the same IP." >&2
16+
echo "If you are using a VPN, try turning it off and running this script again." >&2
17+
echo "You can also set GITHUB_TOKEN to avoid rate limits." >&2
18+
echo "For more details, see: https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" >&2
19+
exit 1
20+
}
1421

15-
# Verify we found a suitable release
22+
# Extract the download URL for this platform
23+
DOWNLOAD_URL="$(echo "${API_RESPONSE}" | grep "browser_download_url.*${OS}.*${ARCH}" | cut -d '"' -f 4 || true)"
1624
if [ -z "${DOWNLOAD_URL}" ]; then
1725
echo "Error: Could not find release for ${OS} ${ARCH}" >&2
26+
echo "GitHub API response (truncated): ${API_RESPONSE:0:200}" >&2
1827
exit 1
1928
fi
2029

pkg/analytics/analytics.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type EventData struct {
1717
}
1818

1919
func TrackEvent(data EventData) error {
20+
if !IsAnalyticsEnabled() {
21+
return nil
22+
}
23+
2024
conf := config.NewConstants()
2125

2226
url := conf.GetBrevAPIURl() + "/api/brevent"

pkg/analytics/posthog.go

Lines changed: 30 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ func getClient() (posthog.Client, error) {
4848
return client, clientErr
4949
}
5050

51-
// IsAnalyticsFeatureEnabled checks the PostHog feature flag to determine
52-
// whether to prompt the user about analytics opt-in.
51+
// IsAnalyticsFeatureEnabled is the remote kill switch for PostHog telemetry only — gating PostHog capture lets us turn it off without a release. It does NOT gate analytics.TrackEvent (the brev-internal endpoint), which has its own channel.
5352
func IsAnalyticsFeatureEnabled() bool {
5453
anonID := GetOrCreateAnalyticsID()
5554
if anonID == "" {
@@ -81,13 +80,26 @@ func RecordCommandStart(cmd *cobra.Command, args []string) {
8180
storedArgs = args
8281
}
8382

84-
// IsAnalyticsEnabled returns whether analytics is enabled and whether the user has been asked.
85-
func IsAnalyticsEnabled() (enabled bool, hasBeenAsked bool) {
83+
// IsAnalyticsEnabled defaults to true; DO_NOT_TRACK and BREV_NO_ANALYTICS override.
84+
func IsAnalyticsEnabled() bool {
85+
if disabled, _ := IsDisabledByEnv(); disabled {
86+
return false
87+
}
8688
settings := readSettings()
8789
if settings.AnalyticsEnabled == nil {
88-
return false, false
90+
return true
91+
}
92+
return *settings.AnalyticsEnabled
93+
}
94+
95+
func IsDisabledByEnv() (disabled bool, varName string) {
96+
if os.Getenv("DO_NOT_TRACK") == "1" {
97+
return true, "DO_NOT_TRACK"
98+
}
99+
if os.Getenv("BREV_NO_ANALYTICS") == "1" {
100+
return true, "BREV_NO_ANALYTICS"
89101
}
90-
return *settings.AnalyticsEnabled, true
102+
return false, ""
91103
}
92104

93105
// SetAnalyticsPreference persists the user's analytics preference.
@@ -139,34 +151,15 @@ func GetOrCreateAnalyticsID() string {
139151
return settings.AnalyticsID
140152
}
141153

142-
// CaptureAnalyticsOptIn sends an event recording the user's analytics consent choice.
143-
// This is sent regardless of the user's choice so we can measure opt-in rates.
144-
func CaptureAnalyticsOptIn(optedIn bool) {
145-
anonID := GetOrCreateAnalyticsID()
146-
if anonID == "" {
147-
return
148-
}
149-
150-
c, err := getClient()
151-
if err != nil {
152-
return
153-
}
154-
155-
_ = c.Enqueue(posthog.Capture{
156-
DistinctId: anonID,
157-
Event: "analytics_opt_in",
158-
Properties: posthog.NewProperties().
159-
Set("opted_in", optedIn).
160-
Set("os", runtime.GOOS).
161-
Set("arch", runtime.GOARCH).
162-
Set("cli_version", version.Version),
163-
})
154+
// shouldCapturePostHog returns true only when both the local opt-out and
155+
// the remote PostHog kill switch agree.
156+
func shouldCapturePostHog() bool {
157+
return IsAnalyticsEnabled() && IsAnalyticsFeatureEnabled()
164158
}
165159

166160
// IdentifyUser links the anonymous analytics ID to a real user ID using PostHog Alias.
167161
func IdentifyUser(userID string) {
168-
enabled, asked := IsAnalyticsEnabled()
169-
if !asked || !enabled {
162+
if !shouldCapturePostHog() {
170163
return
171164
}
172165

@@ -202,22 +195,19 @@ func CaptureCommandError() {
202195
if storedCmd == nil {
203196
return
204197
}
205-
// If CaptureCommand already ran (success path), don't double-capture.
206-
// storedUser being set means PersistentPostRunE ran.
207-
// We only get here on error, so PersistentPostRunE didn't run.
208-
userID := storedUser
209-
if userID == "" {
210-
userID = GetOrCreateAnalyticsID()
211-
}
212-
captureEvent(userID, storedCmd, storedArgs, false)
198+
captureEvent(storedUser, storedCmd, storedArgs, false)
213199
}
214200

215201
func captureEvent(userID string, cmd *cobra.Command, args []string, succeeded bool) {
216-
enabled, asked := IsAnalyticsEnabled()
217-
if !asked || !enabled {
202+
if !shouldCapturePostHog() {
218203
return
219204
}
220205

206+
// Resolve the analytics ID lazily, only after gates pass — avoids writing a
207+
// persistent UUID to ~/.brev/personal_settings.json for opted-out users.
208+
if userID == "" {
209+
userID = GetOrCreateAnalyticsID()
210+
}
221211
if userID == "" {
222212
return
223213
}

pkg/analytics/posthog_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package analytics
2+
3+
import (
4+
"testing"
5+
6+
"github.com/brevdev/brev-cli/pkg/files"
7+
)
8+
9+
func boolPtr(b bool) *bool { return &b }
10+
11+
func TestIsDisabledByEnv(t *testing.T) {
12+
cases := []struct {
13+
name string
14+
envs map[string]string
15+
wantDisabled bool
16+
wantVar string
17+
}{
18+
{"no env vars set", nil, false, ""},
19+
{"DO_NOT_TRACK=1", map[string]string{"DO_NOT_TRACK": "1"}, true, "DO_NOT_TRACK"},
20+
{"BREV_NO_ANALYTICS=1", map[string]string{"BREV_NO_ANALYTICS": "1"}, true, "BREV_NO_ANALYTICS"},
21+
{"DO_NOT_TRACK=0 (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "0"}, false, ""},
22+
{"DO_NOT_TRACK=true (only \"1\" disables)", map[string]string{"DO_NOT_TRACK": "true"}, false, ""},
23+
{"both set — DO_NOT_TRACK reported first", map[string]string{"DO_NOT_TRACK": "1", "BREV_NO_ANALYTICS": "1"}, true, "DO_NOT_TRACK"},
24+
}
25+
for _, c := range cases {
26+
t.Run(c.name, func(t *testing.T) {
27+
t.Setenv("DO_NOT_TRACK", "")
28+
t.Setenv("BREV_NO_ANALYTICS", "")
29+
for k, v := range c.envs {
30+
t.Setenv(k, v)
31+
}
32+
disabled, varName := IsDisabledByEnv()
33+
if disabled != c.wantDisabled {
34+
t.Errorf("disabled = %v, want %v", disabled, c.wantDisabled)
35+
}
36+
if varName != c.wantVar {
37+
t.Errorf("varName = %q, want %q", varName, c.wantVar)
38+
}
39+
})
40+
}
41+
}
42+
43+
func TestIsAnalyticsEnabled(t *testing.T) {
44+
cases := []struct {
45+
name string
46+
stored *bool
47+
envs map[string]string
48+
want bool
49+
}{
50+
{"no preference, no env → default on", nil, nil, true},
51+
{"explicit opt-in, no env", boolPtr(true), nil, true},
52+
{"explicit opt-out, no env", boolPtr(false), nil, false},
53+
{"DO_NOT_TRACK overrides nil", nil, map[string]string{"DO_NOT_TRACK": "1"}, false},
54+
{"DO_NOT_TRACK overrides explicit opt-in", boolPtr(true), map[string]string{"DO_NOT_TRACK": "1"}, false},
55+
{"BREV_NO_ANALYTICS overrides explicit opt-in", boolPtr(true), map[string]string{"BREV_NO_ANALYTICS": "1"}, false},
56+
{"explicit opt-out stays opt-out under env override", boolPtr(false), map[string]string{"DO_NOT_TRACK": "1"}, false},
57+
}
58+
for _, c := range cases {
59+
t.Run(c.name, func(t *testing.T) {
60+
tmp := t.TempDir()
61+
t.Setenv("HOME", tmp)
62+
t.Setenv("DO_NOT_TRACK", "")
63+
t.Setenv("BREV_NO_ANALYTICS", "")
64+
for k, v := range c.envs {
65+
t.Setenv(k, v)
66+
}
67+
68+
if c.stored != nil {
69+
if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{
70+
AnalyticsEnabled: c.stored,
71+
}); err != nil {
72+
t.Fatalf("write settings: %v", err)
73+
}
74+
}
75+
76+
if got := IsAnalyticsEnabled(); got != c.want {
77+
t.Errorf("IsAnalyticsEnabled() = %v, want %v", got, c.want)
78+
}
79+
})
80+
}
81+
}
82+
83+
// SetAnalyticsPreference must not lose other PersonalSettings fields.
84+
func TestSetAnalyticsPreferencePreservesOtherFields(t *testing.T) {
85+
tmp := t.TempDir()
86+
t.Setenv("HOME", tmp)
87+
88+
if err := files.WritePersonalSettings(files.AppFs, tmp, &files.PersonalSettings{
89+
DefaultEditor: "vim",
90+
AnalyticsID: "preexisting-id",
91+
}); err != nil {
92+
t.Fatalf("write seed: %v", err)
93+
}
94+
95+
if err := SetAnalyticsPreference(false); err != nil {
96+
t.Fatalf("SetAnalyticsPreference: %v", err)
97+
}
98+
99+
got, err := files.ReadPersonalSettings(files.AppFs, tmp)
100+
if err != nil {
101+
t.Fatalf("read back: %v", err)
102+
}
103+
if got.DefaultEditor != "vim" {
104+
t.Errorf("DefaultEditor = %q, want %q (other fields must survive)", got.DefaultEditor, "vim")
105+
}
106+
if got.AnalyticsID != "preexisting-id" {
107+
t.Errorf("AnalyticsID = %q, want %q", got.AnalyticsID, "preexisting-id")
108+
}
109+
if got.AnalyticsEnabled == nil || *got.AnalyticsEnabled != false {
110+
t.Errorf("AnalyticsEnabled = %v, want pointer to false", got.AnalyticsEnabled)
111+
}
112+
}

0 commit comments

Comments
 (0)