Skip to content

Commit 27a7394

Browse files
committed
feat: waving flag while requests are in flight
Closes half of #52: a rippling Flagsmith flag on stderr for any request slow enough that a user would otherwise wonder whether the CLI had hung. It is gated on the CLI's own `loading_animation` flag, which means the CLI now evaluates flags about itself, through the same SDK `flagsmith evaluate` uses. Those flags live in Flagsmith's project, not the user's, so `internal/selfflags` is deliberately separate from everything the user configures: a baked-in client-side key (public by construction — every browser SDK ships one) always evaluated against Edge, never `--sdk-api-url` or `flagsmith.json`. It evaluates as an identity rather than taking the environment defaults, so a feature can be aimed at some installations and not others. The targeting key is a random id created on first use and kept in the config directory — not the cache, which is disposable, and re-rolling it would move an installation to the other side of every percentage rollout. Random rather than derived from the user or the machine: it has to be stable, and nothing more. Traits carry what a segment could usefully target — `cli.version`, `os`, `arch`, `is_saas`, and `organisation.id` when the context named one by id. Never by name: resolving that costs a request, and an organisation's name is its company's. The identity is stored rather than transient, so it can also be targeted from the dashboard — which does mean one identity per installation in the CLI's own project. The read path never touches the network, because a spinner cannot wait on the flag that decides whether to draw it. `Enabled` answers from a cache on disk and `Refresh` fills it in the background for the next invocation — abandoned if the process exits first, which only happens on commands too fast to have animated anything. A cold cache is off, matching how the flag was created. Nothing is evaluated when the answer cannot matter: no terminal on stderr (a pipe or a CI log), `FLAGSMITH_DEBUG` (whose trace line would fight a repainting one), or `FLAGSMITH_ANIMATION` set either way — an explicit local answer is also the opt-out for anyone who would rather the CLI did not ask about itself. The flag is drawn by wrapping the shared client's transport, so every request is covered without touching call sites, refcounted so concurrent requests raise one flag between them. It then *stands* between requests rather than being erased after each, or a command making several in a row would flicker; the frame counter is atomic and never reset, so the ripple resumes mid-wave. That needs an owner for the line: `Guard` wraps cobra's writers so the command's own output takes it back, prompts release it explicitly (huh in raw mode bypasses cobra), and Execute releases once more for a command that prints nothing. The cursor is hidden while a flag flies and restored by both of those paths and by a signal handler — hiding it outlives the process, so an interrupt must not leave a terminal with no cursor and no clue. Braille gives four rows to ripple through per line of text. The cloth is five cells of a gale — a wave six dots long against ten of flag — sampled at twenty phases with the stalls stripped, since rounding to whole dot rows makes five of them repeat their predecessor and a repeated frame reads as a hitch. No column moves more than one dot row between frames, which is the difference between rippling and flickering, and a test decodes the glyphs to hold the table to it. Each cell is lit by how high the cloth flies there, so the crests carry the light as the wave travels; colour is a function of the shape, not of the clock. lipgloss and termenv move from indirect to direct requirements — both were already in the module graph via huh, so go.sum is untouched. beep boop
1 parent 5bda84a commit 27a7394

11 files changed

Lines changed: 1751 additions & 3 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ go 1.26
55
require (
66
github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0
77
github.com/charmbracelet/huh v1.0.0
8+
github.com/charmbracelet/lipgloss v1.1.0
89
github.com/fatih/color v1.19.0
910
github.com/itchyny/gojq v0.12.19
11+
github.com/muesli/termenv v0.16.0
1012
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1113
github.com/spf13/cobra v1.10.2
1214
github.com/spf13/pflag v1.0.9
@@ -23,7 +25,6 @@ require (
2325
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
2426
github.com/charmbracelet/bubbletea v1.3.6 // indirect
2527
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
26-
github.com/charmbracelet/lipgloss v1.1.0 // indirect
2728
github.com/charmbracelet/x/ansi v0.9.3 // indirect
2829
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
2930
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -46,7 +47,6 @@ require (
4647
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
4748
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
4849
github.com/muesli/cancelreader v0.2.2 // indirect
49-
github.com/muesli/termenv v0.16.0 // indirect
5050
github.com/ohler55/ojg v1.28.1 // indirect
5151
github.com/rivo/uniseg v0.4.7 // indirect
5252
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

internal/cmd/animation.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"strconv"
8+
"time"
9+
10+
"golang.org/x/term"
11+
12+
"github.com/Flagsmith/flagsmith-cli/v2/internal/selfflags"
13+
"github.com/Flagsmith/flagsmith-cli/v2/internal/spinner"
14+
)
15+
16+
// envAnimation answers loading_animation locally, in either direction.
17+
const envAnimation = "FLAGSMITH_ANIMATION"
18+
19+
// selfFlagTimeout bounds the background evaluation. It is generous: nothing
20+
// waits on it, and the only cost of it running long is a goroutine the process
21+
// may exit out from under.
22+
const selfFlagTimeout = 10 * time.Second
23+
24+
// stderrIsTTY reports whether there is a terminal to animate on; tests stub it.
25+
var stderrIsTTY = func() bool {
26+
return term.IsTerminal(int(os.Stderr.Fd()))
27+
}
28+
29+
// animation decides whether requests raise a waving flag, and whether asking
30+
// Flagsmith could change that answer next time.
31+
//
32+
// Both are false when the answer cannot matter: a pipe or a CI log has nothing
33+
// to animate, and FLAGSMITH_DEBUG puts a trace line on stderr for every request,
34+
// which a repainting flag would fight over. A local answer is also a final one —
35+
// setting FLAGSMITH_ANIMATION is how someone who would rather the CLI did not
36+
// ask about itself says so.
37+
func animation() (draw, ask bool) {
38+
if !stderrIsTTY() || envBool("FLAGSMITH_DEBUG") {
39+
return false, false
40+
}
41+
if os.Getenv(envAnimation) != "" {
42+
return envBool(envAnimation), false
43+
}
44+
return selfflags.Enabled(selfflags.LoadingAnimation), true
45+
}
46+
47+
// The flag for this invocation, and whether its value is worth refreshing. Both
48+
// are decided once, in Execute: the decision reads a file and installs a signal
49+
// handler, neither of which belongs in a code path a test may run in-process
50+
// hundreds of times.
51+
var (
52+
activeFlag *spinner.Spinner
53+
refreshWant bool
54+
)
55+
56+
// animationOut is where the flag is drawn: stderr, which is where progress
57+
// belongs. A var so tests can watch it.
58+
var animationOut io.Writer = os.Stderr
59+
60+
// startAnimation gives the flag the terminal, when there is one and it is
61+
// wanted. cobra's writers are wrapped because the flag stands between requests
62+
// rather than being erased after each: the command's own output is what takes the
63+
// line back, whenever it has something to print.
64+
func startAnimation() {
65+
draw, ask := animation()
66+
refreshWant = ask
67+
if !draw {
68+
return
69+
}
70+
activeFlag = spinner.New(animationOut)
71+
// Both writers, or a standing flag outlives whichever one the command happens
72+
// to print through.
73+
rootCmd.SetOut(activeFlag.Guard(os.Stdout))
74+
rootCmd.SetErr(activeFlag.Guard(os.Stderr))
75+
}
76+
77+
// releaseLine takes the line back from a standing flag. Called before anything
78+
// that writes to the terminal without going through cobra — an interactive
79+
// prompt — and once more before the process exits, for a command that printed
80+
// nothing at all.
81+
func releaseLine() {
82+
if activeFlag != nil {
83+
activeFlag.Release()
84+
}
85+
}
86+
87+
// refreshSelfFlags evaluates the CLI's own flags in the background, for the next
88+
// invocation to read. It builds its own client rather than sharing the command's,
89+
// so the request cannot raise a flag about itself, and is abandoned if the
90+
// process exits first — which only happens on commands too fast to have animated
91+
// anything.
92+
func refreshSelfFlags() {
93+
aud := selfAudience
94+
go func() {
95+
ctx, cancel := context.WithTimeout(context.Background(), selfFlagTimeout)
96+
defer cancel()
97+
selfflags.Refresh(ctx, aud) //nolint:errcheck // best-effort, and nothing to report
98+
}()
99+
}
100+
101+
// selfAudience is what the resolved context contributes to the CLI's own
102+
// evaluation.
103+
var selfAudience selfflags.Audience
104+
105+
func noteAudience(pc *projectContext) {
106+
aud := selfflags.Audience{IsSaas: pc.apiURL() == defaultAPIURL}
107+
if id, ok := pc.Organisation.Value.(int); ok {
108+
aud.Organisation = strconv.Itoa(id)
109+
}
110+
selfAudience = aud
111+
}

internal/cmd/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ var (
2525
func sharedHTTPClient() *http.Client {
2626
httpClientOnce.Do(func() {
2727
httpClientMemo = httpx.New(userAgent())
28+
if activeFlag != nil {
29+
httpClientMemo.Transport = activeFlag.Wrap(httpClientMemo.Transport)
30+
}
31+
// Reaching for the client is the CLI committing to network I/O, which is
32+
// the only time the flag's value is worth evaluating: a command that never
33+
// leaves the machine does not ask about itself either.
34+
if refreshWant {
35+
refreshSelfFlags()
36+
}
2837
})
2938
return httpClientMemo
3039
}

internal/cmd/cmd_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"errors"
8+
"fmt"
89
"io"
910
"net/http"
1011
"net/http/httptest"
@@ -7625,3 +7626,233 @@ func TestRefreshPersistsToKeychain(t *testing.T) {
76257626
t.Errorf("AccessToken = %q, want the refreshed token persisted", creds.AccessToken)
76267627
}
76277628
}
7629+
7630+
// fakeStderrTTY makes the animation think it has a terminal to draw on.
7631+
func fakeStderrTTY(t *testing.T, on bool) {
7632+
t.Helper()
7633+
orig := stderrIsTTY
7634+
stderrIsTTY = func() bool { return on }
7635+
t.Cleanup(func() { stderrIsTTY = orig })
7636+
}
7637+
7638+
// cacheSelfFlag writes the CLI's own flag cache as selfflags reads it, standing
7639+
// in for a refresh that already happened. The shape is pinned in that package.
7640+
func cacheSelfFlag(t *testing.T, name string, enabled bool) {
7641+
t.Helper()
7642+
tmp := t.TempDir()
7643+
t.Setenv("HOME", tmp)
7644+
t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, ".cache"))
7645+
t.Setenv("LocalAppData", tmp)
7646+
dir, err := os.UserCacheDir()
7647+
if err != nil {
7648+
t.Fatal(err)
7649+
}
7650+
if err := os.MkdirAll(filepath.Join(dir, "flagsmith"), 0o700); err != nil {
7651+
t.Fatal(err)
7652+
}
7653+
body := fmt.Sprintf(`{"flags":{%q:%t},"fetchedAt":%q}`, name, enabled, time.Now().Format(time.RFC3339))
7654+
if err := os.WriteFile(filepath.Join(dir, "flagsmith", "selfflags.json"), []byte(body), 0o600); err != nil {
7655+
t.Fatal(err)
7656+
}
7657+
}
7658+
7659+
func TestAnimationDecision(t *testing.T) {
7660+
t.Run("without a terminal there is nothing to draw and nothing to ask", func(t *testing.T) {
7661+
// Given
7662+
cacheSelfFlag(t, "loading_animation", true)
7663+
fakeStderrTTY(t, false)
7664+
7665+
// When
7666+
draw, ask := animation()
7667+
7668+
// Then a piped or CI run neither animates nor evaluates
7669+
if draw || ask {
7670+
t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask)
7671+
}
7672+
})
7673+
7674+
t.Run("FLAGSMITH_DEBUG keeps stderr to itself", func(t *testing.T) {
7675+
// Given
7676+
cacheSelfFlag(t, "loading_animation", true)
7677+
fakeStderrTTY(t, true)
7678+
t.Setenv("FLAGSMITH_DEBUG", "1")
7679+
7680+
// When
7681+
draw, ask := animation()
7682+
7683+
// Then the trace is not fighting a repainting line
7684+
if draw || ask {
7685+
t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask)
7686+
}
7687+
})
7688+
7689+
t.Run("FLAGSMITH_ANIMATION=1 draws without asking", func(t *testing.T) {
7690+
// Given no cached evaluation at all
7691+
cacheSelfFlag(t, "something_else", true)
7692+
fakeStderrTTY(t, true)
7693+
t.Setenv("FLAGSMITH_ANIMATION", "1")
7694+
7695+
// When
7696+
draw, ask := animation()
7697+
7698+
// Then the local answer stands on its own
7699+
if !draw || ask {
7700+
t.Errorf("animation() = (%t, %t), want (true, false)", draw, ask)
7701+
}
7702+
})
7703+
7704+
t.Run("FLAGSMITH_ANIMATION=0 is the opt-out", func(t *testing.T) {
7705+
// Given the flag is on for this CLI
7706+
cacheSelfFlag(t, "loading_animation", true)
7707+
fakeStderrTTY(t, true)
7708+
t.Setenv("FLAGSMITH_ANIMATION", "0")
7709+
7710+
// When
7711+
draw, ask := animation()
7712+
7713+
// Then it neither draws nor phones home again to check
7714+
if draw || ask {
7715+
t.Errorf("animation() = (%t, %t), want (false, false)", draw, ask)
7716+
}
7717+
})
7718+
7719+
t.Run("otherwise the CLI's own flag decides", func(t *testing.T) {
7720+
// Given loading_animation is on in the cached evaluation
7721+
cacheSelfFlag(t, "loading_animation", true)
7722+
fakeStderrTTY(t, true)
7723+
7724+
// When
7725+
draw, ask := animation()
7726+
7727+
// Then it draws, and keeps the evaluation current
7728+
if !draw || !ask {
7729+
t.Errorf("animation() = (%t, %t), want (true, true)", draw, ask)
7730+
}
7731+
})
7732+
7733+
t.Run("a cold cache draws nothing but asks", func(t *testing.T) {
7734+
// Given a machine that has never evaluated the flag
7735+
tmp := t.TempDir()
7736+
t.Setenv("HOME", tmp)
7737+
t.Setenv("XDG_CACHE_HOME", filepath.Join(tmp, ".cache"))
7738+
t.Setenv("LocalAppData", tmp)
7739+
fakeStderrTTY(t, true)
7740+
7741+
// When
7742+
draw, ask := animation()
7743+
7744+
// Then the first run is plain, and the next one need not be
7745+
if draw {
7746+
t.Error("animation() drew on a cold cache")
7747+
}
7748+
if !ask {
7749+
t.Error("animation() did not ask on a cold cache")
7750+
}
7751+
})
7752+
}
7753+
7754+
// TestAnimationOwnsTheTerminalLine covers the wiring rather than the animation:
7755+
// the flag stands between requests, so something must take the line back before
7756+
// the command prints. A build where startAnimation ran but its writers were not
7757+
// installed left the flag stuck in front of the output.
7758+
func TestAnimationOwnsTheTerminalLine(t *testing.T) {
7759+
// Given an invocation that animates
7760+
cacheSelfFlag(t, "loading_animation", true)
7761+
fakeStderrTTY(t, true)
7762+
drawn := &bytes.Buffer{}
7763+
restoreAnimation(t, drawn)
7764+
7765+
// When the animation is started
7766+
startAnimation()
7767+
7768+
// Then a flag exists to be raised
7769+
if activeFlag == nil {
7770+
t.Fatal("startAnimation drew no flag with the flag enabled and a terminal")
7771+
}
7772+
// And neither of the command's writers is the bare stream any more: a flag
7773+
// standing on the line would never be cleared if either were
7774+
if got := rootCmd.OutOrStdout(); got == os.Stdout {
7775+
t.Error("command output writes straight to stdout, past the flag")
7776+
}
7777+
if got := rootCmd.ErrOrStderr(); got == os.Stderr {
7778+
t.Error("command errors write straight to stderr, past the flag")
7779+
}
7780+
// And output written through them still arrives
7781+
if _, err := fmt.Fprint(rootCmd.OutOrStdout(), ""); err != nil {
7782+
t.Errorf("writing through the guarded writer: %v", err)
7783+
}
7784+
}
7785+
7786+
func TestAnimationLeavesWritersAloneWhenOff(t *testing.T) {
7787+
// Given a terminal but the flag turned off locally
7788+
cacheSelfFlag(t, "loading_animation", true)
7789+
fakeStderrTTY(t, true)
7790+
t.Setenv("FLAGSMITH_ANIMATION", "0")
7791+
drawn := &bytes.Buffer{}
7792+
restoreAnimation(t, drawn)
7793+
7794+
// When the animation is started
7795+
startAnimation()
7796+
7797+
// Then nothing was wrapped, and releasing the line is a no-op rather than a
7798+
// nil dereference
7799+
if activeFlag != nil {
7800+
t.Error("startAnimation drew a flag with the animation switched off")
7801+
}
7802+
releaseLine()
7803+
if got := drawn.String(); got != "" {
7804+
t.Errorf("wrote %q to the terminal with the animation off", got)
7805+
}
7806+
}
7807+
7808+
// restoreAnimation points the flag at w and puts the package's animation state —
7809+
// and cobra's writers, which startAnimation replaces — back afterwards.
7810+
func restoreAnimation(t *testing.T, w io.Writer) {
7811+
t.Helper()
7812+
origOut, origWant, origFlag := animationOut, refreshWant, activeFlag
7813+
animationOut = w
7814+
t.Cleanup(func() {
7815+
animationOut, refreshWant, activeFlag = origOut, origWant, origFlag
7816+
rootCmd.SetOut(nil)
7817+
rootCmd.SetErr(nil)
7818+
})
7819+
}
7820+
7821+
func TestNoteAudience(t *testing.T) {
7822+
restore := selfAudience
7823+
t.Cleanup(func() { selfAudience = restore })
7824+
7825+
t.Run("a self-hosted instance and an organisation id", func(t *testing.T) {
7826+
// Given a context pointed at someone's own instance
7827+
pc := &projectContext{
7828+
APIURL: resolved{Value: "https://flagsmith.corp.example"},
7829+
Organisation: resolved{Value: 13},
7830+
}
7831+
7832+
// When it settles
7833+
noteAudience(pc)
7834+
7835+
// Then both facts are available to target: the organisation, and that this
7836+
// is not Flagsmith's own instance
7837+
if got := selfAudience; got.Organisation != "13" || got.IsSaas {
7838+
t.Errorf("selfAudience = %+v, want organisation 13 and not SaaS", got)
7839+
}
7840+
})
7841+
7842+
t.Run("an organisation named rather than numbered is not sent", func(t *testing.T) {
7843+
// Given a context naming its organisation
7844+
pc := &projectContext{
7845+
APIURL: resolved{Value: defaultAPIURL},
7846+
Organisation: resolved{Value: "Acme Corp"},
7847+
}
7848+
7849+
// When it settles
7850+
noteAudience(pc)
7851+
7852+
// Then the name is left behind — resolving it costs a request, and it is
7853+
// the company's name. The default instance is Flagsmith's own.
7854+
if got := selfAudience; got.Organisation != "" || !got.IsSaas {
7855+
t.Errorf("selfAudience = %+v, want no organisation and SaaS", got)
7856+
}
7857+
})
7858+
}

0 commit comments

Comments
 (0)