From 24fa26055e28d9814e7cccef11aa5f6b597a4dc2 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 12 Aug 2026 12:12:18 +0200 Subject: [PATCH 1/3] Added the account balance and top-up commands, and the forfeit notice on fleet delete --- internal/commands/account_balance.go | 98 ++++++++++++++++ internal/commands/account_balance_test.go | 130 ++++++++++++++++++++++ internal/commands/account_topup.go | 59 ++++++++++ internal/commands/account_topup_test.go | 87 +++++++++++++++ internal/commands/balances.go | 58 ++++++++++ internal/commands/fleet_delete.go | 26 ++++- internal/commands/fleet_delete_test.go | 92 +++++++++++++++ main.go | 4 +- main_test.go | 2 + 9 files changed, 553 insertions(+), 3 deletions(-) create mode 100644 internal/commands/account_balance.go create mode 100644 internal/commands/account_balance_test.go create mode 100644 internal/commands/account_topup.go create mode 100644 internal/commands/account_topup_test.go create mode 100644 internal/commands/balances.go diff --git a/internal/commands/account_balance.go b/internal/commands/account_balance.go new file mode 100644 index 0000000..643d184 --- /dev/null +++ b/internal/commands/account_balance.go @@ -0,0 +1,98 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" +) + +func AccountBalance(arguments []string) error { + + jsonOutput := false + + positionals := []string{} + + for _, argument := range arguments { + if argument == "--json" { + jsonOutput = true + continue + } + + positionals = append(positionals, argument) + } + + if len(positionals) > 1 { + return errors.New("account balance takes at most one fleet id") + } + + chosenFleetId := int64(0) + + if len(positionals) == 1 { + parsed, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || parsed < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + chosenFleetId = parsed + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + fetched, err := fetchBalances() + + if err != nil { + return err + } + + balances := []balanceEntry{} + + for _, balance := range fetched { + if chosenFleetId == 0 || balance.Fleet == chosenFleetId { + balances = append(balances, balance) + } + } + + if jsonOutput { + return json.NewEncoder(os.Stdout).Encode(balances) + } + + if len(balances) == 0 { + fmt.Println("No fleets yet. Create one with fleet create.") + return nil + } + + idWidth := len("ID") + nameWidth := len("NAME") + + for _, balance := range balances { + idWidth = max(idWidth, len(strconv.FormatInt(balance.Fleet, 10))) + nameWidth = max(nameWidth, len(fleetNames[balance.Fleet])) + } + + fmt.Printf("%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "BALANCE") + + for _, balance := range balances { + fmt.Printf("%-*d %-*s %s\n", idWidth, balance.Fleet, nameWidth, fleetNames[balance.Fleet], formatBalance(balance)) + } + + return nil +} diff --git a/internal/commands/account_balance_test.go b/internal/commands/account_balance_test.go new file mode 100644 index 0000000..8686d0b --- /dev/null +++ b/internal/commands/account_balance_test.go @@ -0,0 +1,130 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestAccountBalance(t *testing.T) { + tests := []struct { + name string + arguments []string + fleets string + balances string + wantLines []string + wantAbsent []string + wantExact string + wantError string + }{ + { + name: "every fleet", + arguments: []string{}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantLines: []string{"ID", "NAME", "BALANCE", "crew", "€15.00", "pilot", "€0.00"}, + }, + { + name: "one fleet", + arguments: []string{"2"}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantLines: []string{"pilot", "€0.00"}, + wantAbsent: []string{"crew"}, + }, + { + name: "machine readable", + arguments: []string{"--json"}, + fleets: `[{"id":1,"name":"crew","owner":true}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]`, + wantExact: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]` + "\n", + }, + { + name: "machine readable for one fleet", + arguments: []string{"2", "--json"}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantExact: `[{"fleet":2,"balance":"0","currency":"eur"}]` + "\n", + }, + { + name: "machine readable with no fleets", + arguments: []string{"--json"}, + fleets: `[]`, + balances: `[]`, + wantExact: "[]\n", + }, + { + name: "no fleets", + arguments: []string{}, + fleets: `[]`, + balances: `[]`, + wantLines: []string{"No fleets yet"}, + }, + { + name: "an unknown fleet", + arguments: []string{"9"}, + fleets: `[{"id":1,"name":"crew","owner":true}]`, + balances: `[]`, + wantError: "no such fleet", + }, + { + name: "a wordy id", + arguments: []string{"crew"}, + wantError: "shown by fleet list", + }, + { + name: "too many arguments", + arguments: []string{"1", "2"}, + wantError: "at most one fleet id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.fleets) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.balances) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return AccountBalance(test.arguments) + }) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) + } + + for _, want := range test.wantLines { + if !strings.Contains(printed, want) { + t.Errorf("the output %q does not show %q", printed, want) + } + } + + for _, absent := range test.wantAbsent { + if strings.Contains(printed, absent) { + t.Errorf("the output %q shows %q although it was filtered out", printed, absent) + } + } + }) + } +} diff --git a/internal/commands/account_topup.go b/internal/commands/account_topup.go new file mode 100644 index 0000000..1a10365 --- /dev/null +++ b/internal/commands/account_topup.go @@ -0,0 +1,59 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func AccountTopup(arguments []string) error { + + if len(arguments) != 1 { + return errors.New("account topup takes a fleet id") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + request, err := authenticatedRequest(http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/topup", nil) + + if err != nil { + return err + } + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + opened := struct { + Url string `json:"url"` + }{} + + err = json.NewDecoder(response.Body).Decode(&opened) + + if err != nil || opened.Url == "" { + return errors.New("the payment page could not be opened, try again") + } + + fmt.Printf("Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the payment completes.\n", opened.Url) + + return nil +} diff --git a/internal/commands/account_topup_test.go b/internal/commands/account_topup_test.go new file mode 100644 index 0000000..efc8656 --- /dev/null +++ b/internal/commands/account_topup_test.go @@ -0,0 +1,87 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestAccountTopup(t *testing.T) { + tests := []struct { + name string + arguments []string + wantPath string + refusal string + wantError string + }{ + { + name: "a top-up link", + arguments: []string{"3"}, + wantPath: "/fleets/3/topup", + }, + { + name: "no fleet id", + arguments: []string{}, + wantError: "takes a fleet id", + }, + { + name: "too many arguments", + arguments: []string{"3", "4"}, + wantError: "takes a fleet id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by fleet list", + }, + { + name: "the server refuses", + arguments: []string{"9"}, + wantPath: "/fleets/9/topup", + refusal: "no such fleet", + wantError: "the server said: no such fleet", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/topup", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.wantPath { + t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) + } + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + fmt.Fprint(w, `{"url":"https://checkout.stripe.com/c/pay/cs_test_1"}`) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return AccountTopup(test.arguments) + }) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(printed, "https://checkout.stripe.com/c/pay/cs_test_1") { + t.Errorf("the output %q does not show the payment link", printed) + } + }) + } +} diff --git a/internal/commands/balances.go b/internal/commands/balances.go new file mode 100644 index 0000000..fb24614 --- /dev/null +++ b/internal/commands/balances.go @@ -0,0 +1,58 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +type balanceEntry struct { + Fleet int64 `json:"fleet"` + Balance string `json:"balance"` + Currency string `json:"currency"` +} + +func fetchBalances() ([]balanceEntry, error) { + request, err := authenticatedRequest(http.MethodGet, "/balance", nil) + + if err != nil { + return nil, err + } + + response, err := apiClient.Do(request) + + if err != nil { + return nil, fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + balances := []balanceEntry{} + + err = json.NewDecoder(response.Body).Decode(&balances) + + if err != nil { + return nil, err + } + + return balances, nil +} + +func formatBalance(entry balanceEntry) string { + value, err := strconv.ParseFloat(entry.Balance, 64) + + if err != nil { + return entry.Balance + } + + return fmt.Sprintf("€%.2f", value) +} diff --git a/internal/commands/fleet_delete.go b/internal/commands/fleet_delete.go index 0931dc3..545cd0d 100644 --- a/internal/commands/fleet_delete.go +++ b/internal/commands/fleet_delete.go @@ -43,7 +43,31 @@ func FleetDelete(arguments []string) error { return errors.New("no such fleet") } - fmt.Printf("Delete %q and release its devices? [y/N] ", name) + balances, err := fetchBalances() + + if err != nil { + return err + } + + forfeited := "" + + for _, balance := range balances { + if balance.Fleet != fleetId { + continue + } + + value, err := strconv.ParseFloat(balance.Balance, 64) + + if err == nil && value > 0 { + forfeited = formatBalance(balance) + } + } + + if forfeited == "" { + fmt.Printf("Delete %q and release its devices? [y/N] ", name) + } else { + fmt.Printf("Delete %q, release its devices, and forfeit its remaining %s of credit? [y/N] ", name, forfeited) + } answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') diff --git a/internal/commands/fleet_delete_test.go b/internal/commands/fleet_delete_test.go index cce0a63..a951c39 100644 --- a/internal/commands/fleet_delete_test.go +++ b/internal/commands/fleet_delete_test.go @@ -57,6 +57,10 @@ func TestFleetDelete(t *testing.T) { fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) }) + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"fleet":3,"balance":"0","currency":"eur"}]`) + }) + mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { deletedPath = r.URL.Path @@ -84,6 +88,94 @@ func TestFleetDelete(t *testing.T) { } } +func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { + tests := []struct { + name string + balance string + wantPrompt string + wantAbsent string + }{ + { + name: "remaining credit is stated", + balance: `[{"fleet":3,"balance":"12.340000","currency":"eur"}]`, + wantPrompt: "forfeit its remaining €12.34 of credit", + }, + { + name: "an empty balance stays quiet", + balance: `[{"fleet":3,"balance":"0","currency":"eur"}]`, + wantAbsent: "forfeit", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.balance) + }) + + loggedInTestServer(t, mux) + + answerOnStdin(t, "n\n") + + printed, err := captureStdout(t, func() error { + return FleetDelete([]string{"3"}) + }) + + if err != nil { + t.Fatal(err) + } + + if test.wantPrompt != "" && !strings.Contains(printed, test.wantPrompt) { + t.Errorf("the prompt %q does not state %q", printed, test.wantPrompt) + } + + if test.wantAbsent != "" && strings.Contains(printed, test.wantAbsent) { + t.Errorf("the prompt %q mentions %q although nothing is forfeited", printed, test.wantAbsent) + } + }) + } +} + +func TestFleetDeleteRefusesWhenTheBalanceIsUnknown(t *testing.T) { + deletedPath := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "the server could not read the balances", http.StatusServiceUnavailable) + }) + + mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + deletedPath = r.URL.Path + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + answerOnStdin(t, "y\n") + + err := FleetDelete([]string{"3"}) + + if err == nil || !strings.Contains(err.Error(), "could not read the balances") { + t.Fatalf("error = %v, want the server's balance refusal", err) + } + + if deletedPath != "" { + t.Errorf("the server saw %q deleted although the credit could not be stated", deletedPath) + } +} + func TestFleetDeleteUnknownFleet(t *testing.T) { mux := http.NewServeMux() diff --git a/main.go b/main.go index 241be47..250e528 100644 --- a/main.go +++ b/main.go @@ -86,8 +86,8 @@ var sections = []section{ { title: "Account", commands: []command{ - {name: "account balance", arguments: "[fleet_id]", summary: "Show the credit left on your fleets"}, - {name: "account topup", arguments: "", summary: "Add credit to a fleet"}, + {name: "account balance", arguments: "[fleet_id]", summary: "Show the credit left on your fleets", run: commands.AccountBalance}, + {name: "account topup", arguments: "", summary: "Add credit to a fleet", run: commands.AccountTopup}, {name: "account delete", summary: "Delete your account entirely"}, }, }, diff --git a/main_test.go b/main_test.go index 91ab6b0..e9a4d3f 100644 --- a/main_test.go +++ b/main_test.go @@ -19,6 +19,8 @@ func TestResolve(t *testing.T) { {arguments: []string{"fleet", "create", "thermostats"}, name: "fleet create", rest: []string{"thermostats"}, found: true}, {arguments: []string{"member", "add", "someone@example.com"}, name: "member add", rest: []string{"someone@example.com"}, found: true}, {arguments: []string{"key", "create", "42", "production"}, name: "key create", rest: []string{"42", "production"}, found: true}, + {arguments: []string{"account", "balance"}, name: "account balance", rest: []string{}, found: true}, + {arguments: []string{"account", "topup", "42"}, name: "account topup", rest: []string{"42"}, found: true}, {arguments: []string{"upload", "./main.lua", "--device", "sensor-01"}, name: "upload", rest: []string{"./main.lua", "--device", "sensor-01"}, found: true}, {arguments: []string{"fleet"}, found: false}, {arguments: []string{"member"}, found: false}, From 01745e6747e1dd0ef23b2bd316fa81209ae0fb5f Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 12 Aug 2026 13:37:30 +0200 Subject: [PATCH 2/3] Stripped the README back to bare setup steps --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9cb654a..235e98a 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Each option needs a published release. ./superstack ``` -[Nix](https://nixos.org) users need no Go install: `nix develop` enters the -dev shell, and with [direnv](https://direnv.net) hooked into your shell, -`direnv allow` run once in the checkout loads it automatically from then on. +[Nix](https://nixos.org) users: `nix develop` enters the dev shell, and with +[direnv](https://direnv.net) hooked into your shell, `direnv allow` run once +in the checkout loads it automatically from then on. ## Release setup @@ -116,7 +116,7 @@ Do everything below once. git push -u origin version-0.1.0 ``` -1. Merging creates a new commit, and the tag has to point at that one: +1. Tag the new commit that merging created: ```sh git checkout main && git pull From f8536d766320472df6bb81e3a4f0cd4d460fa25c Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 12 Aug 2026 13:50:17 +0200 Subject: [PATCH 3/3] Added press-enter browser opening to the login and top-up links --- internal/commands/account_topup.go | 12 +++++++- internal/commands/account_topup_test.go | 39 ++++++++++++++++++++---- internal/commands/browser.go | 21 +++++++++++++ internal/commands/browser_test.go | 19 ++++++++++++ internal/commands/login.go | 12 ++++++++ internal/commands/login_test.go | 40 +++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 internal/commands/browser.go create mode 100644 internal/commands/browser_test.go diff --git a/internal/commands/account_topup.go b/internal/commands/account_topup.go index 1a10365..ad79c97 100644 --- a/internal/commands/account_topup.go +++ b/internal/commands/account_topup.go @@ -1,11 +1,13 @@ package commands import ( + "bufio" "encoding/json" "errors" "fmt" "io" "net/http" + "os" "strconv" "strings" ) @@ -53,7 +55,15 @@ func AccountTopup(arguments []string) error { return errors.New("the payment page could not be opened, try again") } - fmt.Printf("Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the payment completes.\n", opened.Url) + fmt.Printf("Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the payment completes.\nPress enter to open the browser.\n", opened.Url) + + _, err = bufio.NewReader(os.Stdin).ReadString('\n') + + if err != nil { + return nil + } + + openBrowser(opened.Url) return nil } diff --git a/internal/commands/account_topup_test.go b/internal/commands/account_topup_test.go index efc8656..cb028a1 100644 --- a/internal/commands/account_topup_test.go +++ b/internal/commands/account_topup_test.go @@ -9,14 +9,23 @@ import ( func TestAccountTopup(t *testing.T) { tests := []struct { - name string - arguments []string - wantPath string - refusal string - wantError string + name string + arguments []string + stdin string + wantPath string + wantBrowser bool + refusal string + wantError string }{ { - name: "a top-up link", + name: "a top-up link opened on enter", + arguments: []string{"3"}, + stdin: "\n", + wantPath: "/fleets/3/topup", + wantBrowser: true, + }, + { + name: "a top-up link left alone", arguments: []string{"3"}, wantPath: "/fleets/3/topup", }, @@ -63,6 +72,10 @@ func TestAccountTopup(t *testing.T) { loggedInTestServer(t, mux) + answerOnStdin(t, test.stdin) + + browserOpens := captureBrowserOpens(t) + printed, err := captureStdout(t, func() error { return AccountTopup(test.arguments) }) @@ -82,6 +95,20 @@ func TestAccountTopup(t *testing.T) { if !strings.Contains(printed, "https://checkout.stripe.com/c/pay/cs_test_1") { t.Errorf("the output %q does not show the payment link", printed) } + + select { + case url := <-browserOpens: + if !test.wantBrowser { + t.Errorf("the browser opened %q although enter was never pressed", url) + } else if url != "https://checkout.stripe.com/c/pay/cs_test_1" { + t.Errorf("the browser opened %q, want the payment link", url) + } + + default: + if test.wantBrowser { + t.Error("the browser never opened") + } + } }) } } diff --git a/internal/commands/browser.go b/internal/commands/browser.go new file mode 100644 index 0000000..d5bfab8 --- /dev/null +++ b/internal/commands/browser.go @@ -0,0 +1,21 @@ +package commands + +import ( + "os/exec" + "runtime" +) + +// A variable so tests can swap in a recorder instead of reaching a real +// browser. Opening is best effort: the link is already on screen. +var openBrowser = func(url string) { + switch runtime.GOOS { + case "darwin": + exec.Command("open", url).Start() + + case "windows": + exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + + default: + exec.Command("xdg-open", url).Start() + } +} diff --git a/internal/commands/browser_test.go b/internal/commands/browser_test.go new file mode 100644 index 0000000..de7b52a --- /dev/null +++ b/internal/commands/browser_test.go @@ -0,0 +1,19 @@ +package commands + +import ( + "testing" +) + +func captureBrowserOpens(t *testing.T) chan string { + t.Helper() + + opens := make(chan string, 8) + + previousOpenBrowser := openBrowser + + openBrowser = func(url string) { opens <- url } + + t.Cleanup(func() { openBrowser = previousOpenBrowser }) + + return opens +} diff --git a/internal/commands/login.go b/internal/commands/login.go index c6700dd..f0582e1 100644 --- a/internal/commands/login.go +++ b/internal/commands/login.go @@ -1,6 +1,7 @@ package commands import ( + "bufio" "bytes" "encoding/json" "errors" @@ -134,6 +135,17 @@ func Login(arguments []string) error { fmt.Printf("Copy your one-time code: %s\n", code.UserCode) fmt.Printf("Then enter it at %s\n", enterAt) + fmt.Println("Press enter to open the browser.") + + // The read sits in a goroutine so an unpressed key never stalls the + // poll: the code may just as well be entered on another device. + go func() { + _, err := bufio.NewReader(os.Stdin).ReadString('\n') + + if err == nil { + openBrowser(enterAt) + } + }() // Poll until the code is entered deadline := time.Now().Add(time.Duration(code.ExpiresIn) * time.Second) diff --git a/internal/commands/login_test.go b/internal/commands/login_test.go index 2e42b94..40ea715 100644 --- a/internal/commands/login_test.go +++ b/internal/commands/login_test.go @@ -251,6 +251,10 @@ func TestLogin(t *testing.T) { t.Run(test.name, func(t *testing.T) { isolateKeyStorage(t) + answerOnStdin(t, "") + + captureBrowserOpens(t) + previousMinimum := minimumPollInterval minimumPollInterval = 0 @@ -322,6 +326,42 @@ func TestLogin(t *testing.T) { } } +func TestLoginOpensTheBrowserOnEnter(t *testing.T) { + isolateKeyStorage(t) + + previousMinimum := minimumPollInterval + + minimumPollInterval = 0 + + t.Cleanup(func() { minimumPollInterval = previousMinimum }) + + fakeProviderForLogin(t, "gitlab", 0, []string{`{"access_token": "glpat-test"}`}) + + fakeSuperstack(t) + + browserOpens := captureBrowserOpens(t) + + answerOnStdin(t, "\n") + + _, err := captureStdout(t, func() error { + return Login([]string{"gitlab"}) + }) + + if err != nil { + t.Fatal(err) + } + + select { + case url := <-browserOpens: + if url != "https://gitlab.com/-/user_settings/device?user_code=WDJB-MJHT" { + t.Errorf("the browser opened %q, want the verification link with the code filled in", url) + } + + case <-time.After(2 * time.Second): + t.Fatal("the browser never opened") + } +} + func TestLoginRequiresAProvider(t *testing.T) { tests := []struct { name string