From 9605a4e844aacf33d5420225d242a9923041182b Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Sun, 23 Aug 2026 11:51:17 +0200 Subject: [PATCH 1/2] Use product vocabulary throughout the CLI Rename command keywords, output, and code identifiers to match CONTEXT.md while preserving current server contracts. --- internal/account/account.go | 56 ++-- internal/account/account_test.go | 42 +-- internal/api/api_test.go | 60 ++--- internal/api/apitest/apitest.go | 16 +- internal/api/balances.go | 8 +- internal/api/client.go | 22 +- internal/api/devices.go | 48 +++- internal/api/{keys.go => fleet_keys.go} | 16 +- internal/api/fleets.go | 8 +- internal/api/{session.go => invocation.go} | 20 +- internal/device/device.go | 90 +++---- internal/device/device_test.go | 104 ++++---- internal/dispatch/dispatch.go | 28 +- internal/dispatch/dispatch_test.go | 30 +-- internal/fleet/fleet.go | 76 +++--- internal/fleet/fleet_test.go | 58 ++--- internal/fleetkey/fleetkey.go | 240 ++++++++++++++++++ .../key_test.go => fleetkey/fleetkey_test.go} | 84 +++--- internal/key/key.go | 240 ------------------ internal/login/login.go | 86 +++---- internal/login/login_test.go | 168 ++++++------ internal/member/member.go | 44 ++-- internal/member/member_test.go | 24 +- main.go | 24 +- main_test.go | 10 +- 25 files changed, 812 insertions(+), 790 deletions(-) rename internal/api/{keys.go => fleet_keys.go} (56%) rename internal/api/{session.go => invocation.go} (66%) create mode 100644 internal/fleetkey/fleetkey.go rename internal/{key/key_test.go => fleetkey/fleetkey_test.go} (79%) delete mode 100644 internal/key/key.go diff --git a/internal/account/account.go b/internal/account/account.go index 2a04a92..39320bb 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -14,7 +14,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func Balance(session api.Session, arguments []string) error { +func Balance(invocation api.Invocation, arguments []string) error { positionals, jsonOutput := api.TakeJsonFlag(arguments) if len(positionals) > 1 { @@ -33,7 +33,7 @@ func Balance(session api.Session, arguments []string) error { chosenFleetId = parsed } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -51,7 +51,7 @@ func Balance(session api.Session, arguments []string) error { } } - fetched, err := api.FetchBalances(session) + fetched, err := api.FetchBalances(invocation) if err != nil { return err @@ -66,16 +66,16 @@ func Balance(session api.Session, arguments []string) error { } if jsonOutput { - err = json.NewEncoder(session.Out).Encode(balances) + err = json.NewEncoder(invocation.Out).Encode(balances) return err } if len(balances) == 0 { if chosenFleetId == 0 { - fmt.Fprintln(session.Out, "No fleets yet. Create one with fleet create.") + fmt.Fprintln(invocation.Out, "No fleets yet. Create one with fleet create.") } else { - fmt.Fprintln(session.Out, "No credit on that fleet yet.") + fmt.Fprintln(invocation.Out, "No credit on that fleet yet.") } return nil @@ -101,18 +101,18 @@ func Balance(session api.Session, arguments []string) error { nameWidth = max(nameWidth, len(nameValues[index])) } - fmt.Fprintf(session.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "BALANCE") + fmt.Fprintf(invocation.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "BALANCE") for index, balance := range balances { - fmt.Fprintf(session.Out, "%-*d %-*s %s\n", idWidth, balance.Fleet, nameWidth, nameValues[index], amountValues[index]) + fmt.Fprintf(invocation.Out, "%-*d %-*s %s\n", idWidth, balance.Fleet, nameWidth, nameValues[index], amountValues[index]) } return nil } -func Topup(session api.Session, arguments []string) error { +func TopUp(invocation api.Invocation, arguments []string) error { if len(arguments) != 1 { - return errors.New("account topup takes a fleet id") + return errors.New("account top-up takes a fleet id") } fleetId, err := strconv.ParseInt(arguments[0], 10, 64) @@ -121,17 +121,17 @@ func Topup(session api.Session, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - request, err := api.AuthenticatedRequest(session, http.MethodPost, + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/topup", nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -150,34 +150,34 @@ func Topup(session api.Session, arguments []string) error { return errors.New("could not open the top-up page, try again") } - fmt.Fprintf(session.Out, "Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the top-up completes.\nPress enter to open the browser.\n", api.Printable(opened.Url)) + fmt.Fprintf(invocation.Out, "Open this page to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the top-up completes.\nPress enter to open the browser.\n", api.Printable(opened.Url)) - _, err = bufio.NewReader(session.In).ReadString('\n') + _, err = bufio.NewReader(invocation.In).ReadString('\n') if err != nil { return nil } - session.OpenBrowser(opened.Url) + invocation.OpenBrowser(opened.Url) return nil } -func Delete(session api.Session, arguments []string) error { +func Delete(invocation api.Invocation, arguments []string) error { if len(arguments) != 0 { return errors.New("account delete takes no arguments") } - request, err := api.AuthenticatedRequest(session, http.MethodGet, "/fleets", nil) + request, err := api.AuthenticatedRequest(invocation, http.MethodGet, "/fleets", nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } if response.StatusCode != http.StatusOK { @@ -190,27 +190,27 @@ func Delete(session api.Session, arguments []string) error { response.Body.Close() - fmt.Fprint(session.Out, "Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ") + fmt.Fprint(invocation.Out, "Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ") - answer, _ := bufio.NewReader(session.In).ReadString('\n') + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') answer = strings.ToLower(strings.TrimSpace(answer)) if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing deleted.") + fmt.Fprintln(invocation.Out, "Nothing deleted.") return nil } - request, err = api.AuthenticatedRequest(session, http.MethodDelete, "/account", nil) + request, err = api.AuthenticatedRequest(invocation, http.MethodDelete, "/account", nil) if err != nil { return err } - response, err = session.Client.Do(request) + response, err = invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -219,9 +219,9 @@ func Delete(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintln(session.Out, "Account deleted.") + fmt.Fprintln(invocation.Out, "Account deleted.") - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { return err diff --git a/internal/account/account_test.go b/internal/account/account_test.go index 2990f7f..92c48ca 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -132,9 +132,9 @@ func TestAccountBalance(t *testing.T) { fmt.Fprint(w, test.balances) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Balance(session, test.arguments) + err := Balance(invocation, test.arguments) printed := out.String() @@ -169,7 +169,7 @@ func TestAccountBalance(t *testing.T) { } } -func TestAccountTopup(t *testing.T) { +func TestAccountTopUp(t *testing.T) { tests := []struct { name string arguments []string @@ -181,14 +181,14 @@ func TestAccountTopup(t *testing.T) { wantError string }{ { - name: "a top-up link opened on enter", + name: "the top-up page opened on enter", arguments: []string{"3"}, stdin: "\n", wantPath: "/fleets/3/topup", wantBrowser: true, }, { - name: "a top-up link left alone", + name: "the top-up page left alone", arguments: []string{"3"}, wantPath: "/fleets/3/topup", }, @@ -245,13 +245,13 @@ func TestAccountTopup(t *testing.T) { fmt.Fprint(w, `{"url":"https://checkout.stripe.com/c/pay/cs_test_1"}`) }) - session, out := apitest.LoggedInSession(t, mux) - session.In = strings.NewReader(test.stdin) + invocation, out := apitest.LoggedInInvocation(t, mux) + invocation.In = strings.NewReader(test.stdin) browserOpens := make(chan string, 1) - session.OpenBrowser = func(url string) { browserOpens <- url } + invocation.OpenBrowser = func(url string) { browserOpens <- url } - err := Topup(session, test.arguments) + err := TopUp(invocation, test.arguments) printed := out.String() @@ -268,7 +268,7 @@ 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) + t.Errorf("the output %q does not show the top-up page", printed) } if !strings.Contains(printed, "The credit appears on the balance once the top-up completes.") { @@ -280,7 +280,7 @@ func TestAccountTopup(t *testing.T) { 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) + t.Errorf("the browser opened %q, want the top-up page", url) } default: @@ -367,17 +367,17 @@ func TestAccountDelete(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) } - session.In = strings.NewReader(test.answer) + invocation.In = strings.NewReader(test.answer) - err = Delete(session, test.arguments) + err = Delete(invocation, test.arguments) printed := out.String() @@ -411,14 +411,14 @@ func TestAccountDelete(t *testing.T) { } func TestAccountDeleteAsksNothingWhenTheServerIsGone(t *testing.T) { - session, out := apitest.LoggedInSession(t, http.NewServeMux()) + invocation, out := apitest.LoggedInInvocation(t, http.NewServeMux()) gone := httptest.NewServer(http.NotFoundHandler()) gone.Close() - session.Base = gone.URL + invocation.Base = gone.URL - err := Delete(session, nil) + err := Delete(invocation, nil) if err == nil || !strings.Contains(err.Error(), "could not be reached") { t.Fatalf("error = %v, want it to mention the server could not be reached", err) @@ -470,11 +470,11 @@ func TestAccountDeleteStopsWhenTheProbeIsRefused(t *testing.T) { deleted = true }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader("y\n") + invocation.In = strings.NewReader("y\n") - err := Delete(session, nil) + err := Delete(invocation, nil) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("error = %v, want the server's own refusal", err) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 10be098..684a84b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -17,17 +17,17 @@ import ( ) func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { - temporary := apitest.IsolateKeyStorage(t) + temporary := apitest.IsolateLoginKeyStorage(t) if runtime.GOOS != "linux" { - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) } if !strings.HasPrefix(path, temporary) { - t.Fatalf("api.KeyPath() = %q, want it under the isolated home", path) + t.Fatalf("api.LoginKeyPath() = %q, want it under the isolated home", path) } return @@ -47,18 +47,18 @@ func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Setenv("XDG_STATE_HOME", test.stateHome) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) } if path != test.wantPath { - t.Errorf("api.KeyPath() = %q, want %q", path, test.wantPath) + t.Errorf("api.LoginKeyPath() = %q, want %q", path, test.wantPath) } if strings.Contains(path, ".config") { - t.Errorf("api.KeyPath() = %q, must never sit in ~/.config", path) + t.Errorf("api.LoginKeyPath() = %q, must never sit in ~/.config", path) } }) } @@ -89,9 +89,9 @@ func TestApiRequestBase(t *testing.T) { base = api.DefaultBase } - session := api.NewSession(base, "1.2.3", strings.NewReader(""), &bytes.Buffer{}) + invocation := api.NewInvocation(base, "1.2.3", strings.NewReader(""), &bytes.Buffer{}) - request, err := api.Request(session, http.MethodGet, "/login", nil) + request, err := api.Request(invocation, http.MethodGet, "/login", nil) if err != nil { t.Fatal(err) @@ -111,23 +111,23 @@ func TestApiRequestBase(t *testing.T) { func TestFetchFleetsFailures(t *testing.T) { tests := []struct { - name string - loggedIn bool - storedKey string - status int - body string - wantError string + name string + loggedIn bool + storedLoginKey string + status int + body string + wantError string }{ {name: "not logged in", wantError: "not logged in"}, - {name: "empty key file", loggedIn: true, storedKey: " \n", wantError: "not logged in"}, + {name: "empty login key file", loggedIn: true, storedLoginKey: " \n", wantError: "not logged in"}, {name: "server refusal", loggedIn: true, status: http.StatusServiceUnavailable, body: "fleets unavailable", wantError: "fleets unavailable"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) - session := api.Session{} + invocation := api.Invocation{} if test.loggedIn { mux := http.NewServeMux() @@ -139,16 +139,16 @@ func TestFetchFleetsFailures(t *testing.T) { fmt.Fprint(w, test.body) }) - session, _ = apitest.LoggedInSession(t, mux) + invocation, _ = apitest.LoggedInInvocation(t, mux) - if test.storedKey != "" { - path, err := api.KeyPath() + if test.storedLoginKey != "" { + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) } - err = os.WriteFile(path, []byte(test.storedKey), 0o600) + err = os.WriteFile(path, []byte(test.storedLoginKey), 0o600) if err != nil { t.Fatal(err) @@ -156,7 +156,7 @@ func TestFetchFleetsFailures(t *testing.T) { } } - _, err := api.FetchFleets(session) + _, err := api.FetchFleets(invocation) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) @@ -184,9 +184,9 @@ func TestFetchDevicesFailures(t *testing.T) { fmt.Fprint(w, test.body) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - _, err := api.FetchDevices(session) + _, err := api.FetchDevices(invocation) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) @@ -195,14 +195,14 @@ func TestFetchDevicesFailures(t *testing.T) { } } -func TestFetchKeysFailures(t *testing.T) { +func TestFetchFleetKeysFailures(t *testing.T) { tests := []struct { name string status int body string wantError string }{ - {name: "server refusal", status: http.StatusServiceUnavailable, body: "keys unavailable", wantError: "keys unavailable"}, + {name: "server refusal", status: http.StatusServiceUnavailable, body: "fleet keys unavailable", wantError: "fleet keys unavailable"}, {name: "undecodable body", status: http.StatusOK, body: `{`, wantError: "could not be read"}, {name: "a refusal carrying control characters", status: http.StatusServiceUnavailable, body: "\x1b[2Kgone", wantError: `\x1b[2Kgone`}, } @@ -215,9 +215,9 @@ func TestFetchKeysFailures(t *testing.T) { fmt.Fprint(w, test.body) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - _, err := api.FetchKeys(session) + _, err := api.FetchFleetKeys(invocation) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) @@ -244,9 +244,9 @@ func TestFetchBalancesFailures(t *testing.T) { fmt.Fprint(w, test.body) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - _, err := api.FetchBalances(session) + _, err := api.FetchBalances(invocation) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) diff --git a/internal/api/apitest/apitest.go b/internal/api/apitest/apitest.go index b2cc7ee..a393cc6 100644 --- a/internal/api/apitest/apitest.go +++ b/internal/api/apitest/apitest.go @@ -12,7 +12,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func IsolateKeyStorage(t *testing.T) string { +func IsolateLoginKeyStorage(t *testing.T) string { t.Helper() temporary := t.TempDir() @@ -24,12 +24,12 @@ func IsolateKeyStorage(t *testing.T) string { return temporary } -func LoggedInSession(t *testing.T, handler http.Handler) (api.Session, *bytes.Buffer) { +func LoggedInInvocation(t *testing.T, handler http.Handler) (api.Invocation, *bytes.Buffer) { t.Helper() - IsolateKeyStorage(t) + IsolateLoginKeyStorage(t) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) @@ -49,7 +49,7 @@ func LoggedInSession(t *testing.T, handler http.Handler) (api.Session, *bytes.Bu authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer ssk_test" { - t.Errorf("%s %s carried authorization %q, want the stored key", + t.Errorf("%s %s carried authorization %q, want the stored login key", r.Method, r.URL.Path, r.Header.Get("Authorization")) } @@ -61,8 +61,8 @@ func LoggedInSession(t *testing.T, handler http.Handler) (api.Session, *bytes.Bu t.Cleanup(server.Close) out := &bytes.Buffer{} - session := api.NewSession(server.URL, "test", strings.NewReader(""), out) - session.OpenBrowser = func(url string) {} + invocation := api.NewInvocation(server.URL, "test", strings.NewReader(""), out) + invocation.OpenBrowser = func(url string) {} - return session, out + return invocation, out } diff --git a/internal/api/balances.go b/internal/api/balances.go index d82258a..24479d2 100644 --- a/internal/api/balances.go +++ b/internal/api/balances.go @@ -13,17 +13,17 @@ type BalanceEntry struct { Currency string `json:"currency"` } -func FetchBalances(session Session) ([]BalanceEntry, error) { - request, err := AuthenticatedRequest(session, http.MethodGet, "/balance", nil) +func FetchBalances(invocation Invocation) ([]BalanceEntry, error) { + request, err := AuthenticatedRequest(invocation, http.MethodGet, "/balance", nil) if err != nil { return nil, err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return nil, errors.New("the server could not be reached, check your connection") + return nil, errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() diff --git a/internal/api/client.go b/internal/api/client.go index 8485720..66d6095 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -12,26 +12,26 @@ import ( "strings" ) -func Request(session Session, method string, path string, reader io.Reader) (*http.Request, error) { - request, err := http.NewRequest(method, strings.TrimSuffix(session.Base, "/")+path, reader) +func Request(invocation Invocation, method string, path string, reader io.Reader) (*http.Request, error) { + request, err := http.NewRequest(method, strings.TrimSuffix(invocation.Base, "/")+path, reader) if err != nil { return nil, err } - request.Header.Set("User-Agent", "superstack/"+session.Version) + request.Header.Set("User-Agent", "superstack/"+invocation.Version) return request, nil } -func AuthenticatedRequest(session Session, method string, path string, reader io.Reader) (*http.Request, error) { - storedKeyPath, err := KeyPath() +func AuthenticatedRequest(invocation Invocation, method string, path string, reader io.Reader) (*http.Request, error) { + loginKeyPath, err := LoginKeyPath() if err != nil { return nil, err } - keyBytes, err := os.ReadFile(storedKeyPath) + loginKeyBytes, err := os.ReadFile(loginKeyPath) if errors.Is(err, fs.ErrNotExist) { return nil, errors.New("you are not logged in, run login first") @@ -41,19 +41,19 @@ func AuthenticatedRequest(session Session, method string, path string, reader io return nil, errors.New("the login stored on this computer could not be read") } - key := strings.TrimSpace(string(keyBytes)) + loginKey := strings.TrimSpace(string(loginKeyBytes)) - if key == "" { + if loginKey == "" { return nil, errors.New("you are not logged in, run login first") } - request, err := Request(session, method, path, reader) + request, err := Request(invocation, method, path, reader) if err != nil { return nil, err } - request.Header.Set("Authorization", "Bearer "+key) + request.Header.Set("Authorization", "Bearer "+loginKey) return request, nil } @@ -81,7 +81,7 @@ func Decode(response *http.Response, value any) error { return nil } -func KeyPath() (string, error) { +func LoginKeyPath() (string, error) { if runtime.GOOS == "linux" { stateHome := os.Getenv("XDG_STATE_HOME") diff --git a/internal/api/devices.go b/internal/api/devices.go index 5df2cd5..82804d4 100644 --- a/internal/api/devices.go +++ b/internal/api/devices.go @@ -6,26 +6,26 @@ import ( ) type DeviceEntry struct { - Imei string `json:"imei"` - Name *string `json:"name"` - FleetId int64 `json:"fleet_id"` - LastSeenAt *string `json:"last_seen_at"` - ReportedState *int `json:"reported_state"` - StorageUsed *int64 `json:"storage_used"` - StorageTotal *int64 `json:"storage_total"` + Imei string `json:"imei"` + Name *string `json:"name"` + FleetId int64 `json:"fleet_id"` + LastSeenAt *string `json:"last_seen_at"` + RunState *int `json:"run_state"` + StorageUsed *int64 `json:"storage_used"` + StorageTotal *int64 `json:"storage_total"` } -func FetchDevices(session Session) ([]DeviceEntry, error) { - request, err := AuthenticatedRequest(session, http.MethodGet, "/devices", nil) +func FetchDevices(invocation Invocation) ([]DeviceEntry, error) { + request, err := AuthenticatedRequest(invocation, http.MethodGet, "/devices", nil) if err != nil { return nil, err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return nil, errors.New("the server could not be reached, check your connection") + return nil, errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -34,13 +34,35 @@ func FetchDevices(session Session) ([]DeviceEntry, error) { return nil, ServerError(response) } - devices := []DeviceEntry{} + serverDevices := []struct { + Imei string `json:"imei"` + Name *string `json:"name"` + FleetId int64 `json:"fleet_id"` + LastSeenAt *string `json:"last_seen_at"` + RunState *int `json:"reported_state"` + StorageUsed *int64 `json:"storage_used"` + StorageTotal *int64 `json:"storage_total"` + }{} - err = Decode(response, &devices) + err = Decode(response, &serverDevices) if err != nil { return nil, err } + devices := make([]DeviceEntry, len(serverDevices)) + + for index, device := range serverDevices { + devices[index] = DeviceEntry{ + Imei: device.Imei, + Name: device.Name, + FleetId: device.FleetId, + LastSeenAt: device.LastSeenAt, + RunState: device.RunState, + StorageUsed: device.StorageUsed, + StorageTotal: device.StorageTotal, + } + } + return devices, nil } diff --git a/internal/api/keys.go b/internal/api/fleet_keys.go similarity index 56% rename from internal/api/keys.go rename to internal/api/fleet_keys.go index 2e78885..c18e6d7 100644 --- a/internal/api/keys.go +++ b/internal/api/fleet_keys.go @@ -5,24 +5,24 @@ import ( "net/http" ) -type KeyEntry struct { +type FleetKeyEntry struct { Id int64 `json:"id"` Fleet int64 `json:"fleet"` Label string `json:"label"` Suffix string `json:"suffix"` } -func FetchKeys(session Session) ([]KeyEntry, error) { - request, err := AuthenticatedRequest(session, http.MethodGet, "/keys", nil) +func FetchFleetKeys(invocation Invocation) ([]FleetKeyEntry, error) { + request, err := AuthenticatedRequest(invocation, http.MethodGet, "/keys", nil) if err != nil { return nil, err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return nil, errors.New("the server could not be reached, check your connection") + return nil, errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -31,13 +31,13 @@ func FetchKeys(session Session) ([]KeyEntry, error) { return nil, ServerError(response) } - keys := []KeyEntry{} + fleetKeys := []FleetKeyEntry{} - err = Decode(response, &keys) + err = Decode(response, &fleetKeys) if err != nil { return nil, err } - return keys, nil + return fleetKeys, nil } diff --git a/internal/api/fleets.go b/internal/api/fleets.go index 5c8593e..d3a8a4c 100644 --- a/internal/api/fleets.go +++ b/internal/api/fleets.go @@ -11,17 +11,17 @@ type FleetEntry struct { Owner bool `json:"owner"` } -func FetchFleets(session Session) ([]FleetEntry, error) { - request, err := AuthenticatedRequest(session, http.MethodGet, "/fleets", nil) +func FetchFleets(invocation Invocation) ([]FleetEntry, error) { + request, err := AuthenticatedRequest(invocation, http.MethodGet, "/fleets", nil) if err != nil { return nil, err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return nil, errors.New("the server could not be reached, check your connection") + return nil, errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() diff --git a/internal/api/session.go b/internal/api/invocation.go similarity index 66% rename from internal/api/session.go rename to internal/api/invocation.go index 5852251..5a6e9ba 100644 --- a/internal/api/session.go +++ b/internal/api/invocation.go @@ -11,7 +11,7 @@ import ( const DefaultBase = "https://supernext.siliconwitchery.com" -type Session struct { +type Invocation struct { Base string GithubBase string GitlabBase string @@ -19,11 +19,11 @@ type Session struct { Client *http.Client In io.Reader Out io.Writer - OpenBrowser func(link string) + OpenBrowser func(address string) } -func NewSession(base string, version string, in io.Reader, out io.Writer) Session { - return Session{ +func NewInvocation(base string, version string, in io.Reader, out io.Writer) Invocation { + return Invocation{ Base: base, GithubBase: "https://github.com", GitlabBase: "https://gitlab.com", @@ -35,14 +35,14 @@ func NewSession(base string, version string, in io.Reader, out io.Writer) Sessio } } -func openBrowser(link string) { - address, err := url.Parse(link) +func openBrowser(address string) { + parsed, err := url.Parse(address) if err != nil { return } - if address.Scheme != "http" && address.Scheme != "https" { + if parsed.Scheme != "http" && parsed.Scheme != "https" { return } @@ -50,13 +50,13 @@ func openBrowser(link string) { switch runtime.GOOS { case "darwin": - command = exec.Command("open", link) + command = exec.Command("open", address) case "windows": - command = exec.Command("rundll32", "url.dll,FileProtocolHandler", link) + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", address) default: - command = exec.Command("xdg-open", link) + command = exec.Command("xdg-open", address) } _ = command.Start() diff --git a/internal/device/device.go b/internal/device/device.go index fbba942..d89fe28 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -14,9 +14,9 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func Claim(session api.Session, arguments []string) error { +func Pair(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 && len(arguments) != 3 { - return errors.New("device claim takes an IMEI, a fleet id, and an optional name") + return errors.New("device pair takes an IMEI, a fleet id, and an optional name") } imei := arguments[0] @@ -31,7 +31,7 @@ func Claim(session api.Session, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -49,7 +49,7 @@ func Claim(session api.Session, arguments []string) error { return errors.New("no such fleet") } - fmt.Fprintln(session.Out, "Press the pairing button on the device to finish claiming it.") + fmt.Fprintln(invocation.Out, "Press the pairing button on the device to finish pairing it.") payload := map[string]string{"imei": imei} @@ -63,7 +63,7 @@ func Claim(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPost, + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/devices", bytes.NewReader(body)) if err != nil { @@ -72,12 +72,12 @@ func Claim(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - claimClient := &http.Client{Timeout: 90 * time.Second} + pairingClient := &http.Client{Timeout: 90 * time.Second} - response, err := claimClient.Do(request) + response, err := pairingClient.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -86,12 +86,12 @@ func Claim(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Claimed device %s into fleet %q.\n", imei, fleetName) + fmt.Fprintf(invocation.Out, "Paired device %s with fleet %q.\n", imei, fleetName) return nil } -func List(session api.Session, arguments []string) error { +func List(invocation api.Invocation, arguments []string) error { positionals, jsonOutput := api.TakeJsonFlag(arguments) if len(positionals) > 1 { @@ -110,13 +110,13 @@ func List(session api.Session, arguments []string) error { chosenFleetId = parsed } - devices, err := api.FetchDevices(session) + devices, err := api.FetchDevices(invocation) if err != nil { return err } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -143,16 +143,16 @@ func List(session api.Session, arguments []string) error { } if jsonOutput { - err = json.NewEncoder(session.Out).Encode(filtered) + err = json.NewEncoder(invocation.Out).Encode(filtered) return err } if len(filtered) == 0 { if chosenFleetId == 0 { - fmt.Fprintln(session.Out, "No devices yet. Claim one with device claim.") + fmt.Fprintln(invocation.Out, "No devices yet. Pair one with device pair.") } else { - fmt.Fprintln(session.Out, "No devices in that fleet.") + fmt.Fprintln(invocation.Out, "No devices in that fleet.") } return nil @@ -161,12 +161,12 @@ func List(session api.Session, arguments []string) error { imeiWidth := len("IMEI") nameWidth := len("NAME") fleetWidth := len("FLEET") - stateWidth := len("STATE") + runStateWidth := len("RUN STATE") storageWidth := len("STORAGE") imeiValues := make([]string, len(filtered)) nameValues := make([]string, len(filtered)) fleetValues := make([]string, len(filtered)) - stateValues := make([]string, len(filtered)) + runStateValues := make([]string, len(filtered)) storageValues := make([]string, len(filtered)) lastSeenValues := make([]string, len(filtered)) @@ -200,16 +200,16 @@ func List(session api.Session, arguments []string) error { } } - state := "unknown" + runState := "unknown" - if device.ReportedState != nil { - switch *device.ReportedState { + if device.RunState != nil { + switch *device.RunState { case 2: - state = "running" + runState = "running" case 3: - state = "stopped" + runState = "stopped" case 4: - state = "crashed" + runState = "crashed" } } @@ -239,30 +239,30 @@ func List(session api.Session, arguments []string) error { imeiValues[index] = api.Printable(device.Imei) nameValues[index] = api.Printable(name) fleetValues[index] = api.Printable(fleetName) - stateValues[index] = state + runStateValues[index] = runState storageValues[index] = storage lastSeenValues[index] = lastSeen imeiWidth = max(imeiWidth, len(imeiValues[index])) nameWidth = max(nameWidth, len(nameValues[index])) fleetWidth = max(fleetWidth, len(fleetValues[index])) - stateWidth = max(stateWidth, len(stateValues[index])) + runStateWidth = max(runStateWidth, len(runStateValues[index])) storageWidth = max(storageWidth, len(storageValues[index])) } - fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", + fmt.Fprintf(invocation.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", - stateWidth, "STATE", storageWidth, "STORAGE", "LAST SEEN") + runStateWidth, "RUN STATE", storageWidth, "STORAGE", "LAST SEEN") for index := range filtered { - fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", + fmt.Fprintf(invocation.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", imeiWidth, imeiValues[index], nameWidth, nameValues[index], fleetWidth, fleetValues[index], - stateWidth, stateValues[index], storageWidth, storageValues[index], lastSeenValues[index]) + runStateWidth, runStateValues[index], storageWidth, storageValues[index], lastSeenValues[index]) } return nil } -func Rename(session api.Session, arguments []string) error { +func Rename(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 { return errors.New("device rename takes an IMEI and a new name, quoted if it has spaces") } @@ -285,7 +285,7 @@ func Rename(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPatch, "/devices/"+imei, bytes.NewReader(body)) + request, err := api.AuthenticatedRequest(invocation, http.MethodPatch, "/devices/"+imei, bytes.NewReader(body)) if err != nil { return err @@ -293,10 +293,10 @@ func Rename(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -305,14 +305,14 @@ func Rename(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Renamed device %s to %q.\n", imei, name) + fmt.Fprintf(invocation.Out, "Renamed device %s to %q.\n", imei, name) return nil } -func Release(session api.Session, arguments []string) error { +func Unpair(invocation api.Invocation, arguments []string) error { if len(arguments) != 1 { - return errors.New("device release takes an IMEI") + return errors.New("device unpair takes an IMEI") } imei := arguments[0] @@ -321,7 +321,7 @@ func Release(session api.Session, arguments []string) error { return errors.New("the IMEI is the 15-digit number printed on the device") } - devices, err := api.FetchDevices(session) + devices, err := api.FetchDevices(invocation) if err != nil { return err @@ -347,7 +347,7 @@ func Release(session api.Session, arguments []string) error { return errors.New("no such device, device list shows yours") } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -365,27 +365,27 @@ func Release(session api.Session, arguments []string) error { return errors.New("no such device, device list shows yours") } - fmt.Fprintf(session.Out, "Release device %q from fleet %q? It wipes the device's files and restarts its code, and claiming it again means pressing its pairing button in person. [y/N] ", label, fleetName) + fmt.Fprintf(invocation.Out, "Unpair device %q from fleet %q? It wipes the device's user files and restarts Lua, and pairing it again means pressing its pairing button in person. [y/N] ", label, fleetName) - answer, _ := bufio.NewReader(session.In).ReadString('\n') + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') answer = strings.ToLower(strings.TrimSpace(answer)) if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing released.") + fmt.Fprintln(invocation.Out, "Nothing unpaired.") return nil } - request, err := api.AuthenticatedRequest(session, http.MethodDelete, "/devices/"+imei, nil) + request, err := api.AuthenticatedRequest(invocation, http.MethodDelete, "/devices/"+imei, nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -394,7 +394,7 @@ func Release(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Released device %q from fleet %q.\n", label, fleetName) + fmt.Fprintf(invocation.Out, "Unpaired device %q from fleet %q.\n", label, fleetName) return nil } diff --git a/internal/device/device_test.go b/internal/device/device_test.go index d27bbc6..e00fa32 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -12,7 +12,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api/apitest" ) -func TestDeviceClaim(t *testing.T) { +func TestDevicePair(t *testing.T) { tests := []struct { name string statusCode int @@ -23,21 +23,21 @@ func TestDeviceClaim(t *testing.T) { { name: "button pressed", statusCode: http.StatusNoContent, - wantOutput: "Press the pairing button on the device to finish claiming it.\nClaimed device 354820091234567 into fleet \"pilot\".\n", + wantOutput: "Press the pairing button on the device to finish pairing it.\nPaired device 354820091234567 with fleet \"pilot\".\n", }, { name: "button not pressed", statusCode: http.StatusRequestTimeout, message: "the button was not pressed in time", - wantOutput: "Press the pairing button on the device to finish claiming it.\n", + wantOutput: "Press the pairing button on the device to finish pairing it.\n", wantError: "the button was not pressed in time", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - claimedImei := "" - claimedName := "" + pairedImei := "" + pairedName := "" mux := http.NewServeMux() mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { @@ -50,8 +50,8 @@ func TestDeviceClaim(t *testing.T) { }{} json.NewDecoder(r.Body).Decode(&body) - claimedImei = body.Imei - claimedName = body.Name + pairedImei = body.Imei + pairedName = body.Name if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) @@ -66,9 +66,9 @@ func TestDeviceClaim(t *testing.T) { w.WriteHeader(test.statusCode) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Claim(session, []string{"354820091234567", "3", "roof sensor"}) + err := Pair(invocation, []string{"354820091234567", "3", "roof sensor"}) printed := out.String() @@ -80,8 +80,8 @@ func TestDeviceClaim(t *testing.T) { t.Fatalf("error = %v, want %q", err, test.wantError) } - if claimedImei != "354820091234567" || claimedName != "roof sensor" { - t.Errorf("the server received IMEI %q and name %q", claimedImei, claimedName) + if pairedImei != "354820091234567" || pairedName != "roof sensor" { + t.Errorf("the server received IMEI %q and name %q", pairedImei, pairedName) } if printed != test.wantOutput { @@ -91,7 +91,7 @@ func TestDeviceClaim(t *testing.T) { } } -func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { +func TestDevicePairOmitsAnAbsentName(t *testing.T) { nameWasPresent := false mux := http.NewServeMux() @@ -105,9 +105,9 @@ func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Claim(session, []string{"354820091234567", "3"}) + err := Pair(invocation, []string{"354820091234567", "3"}) if err != nil { t.Fatal(err) @@ -117,12 +117,12 @@ func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { t.Error("the request included a name although none was given") } - if out.String() != "Press the pairing button on the device to finish claiming it.\nClaimed device 354820091234567 into fleet \"pilot\".\n" { + if out.String() != "Press the pairing button on the device to finish pairing it.\nPaired device 354820091234567 with fleet \"pilot\".\n" { t.Errorf("output = %q", out.String()) } } -func TestDeviceClaimArguments(t *testing.T) { +func TestDevicePairArguments(t *testing.T) { tests := []struct { name string arguments []string @@ -137,7 +137,7 @@ func TestDeviceClaimArguments(t *testing.T) { } for _, test := range tests { - err := Claim(api.Session{}, test.arguments) + err := Pair(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) @@ -145,15 +145,15 @@ func TestDeviceClaimArguments(t *testing.T) { } } -func TestDeviceClaimUnknownFleet(t *testing.T) { +func TestDevicePairUnknownFleet(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - err := Claim(session, []string{"354820091234567", "9"}) + err := Pair(invocation, []string{"354820091234567", "9"}) if err == nil || err.Error() != "no such fleet" { t.Fatalf("error = %v", err) @@ -179,24 +179,24 @@ func TestDeviceList(t *testing.T) { fleets string refusal string }{ - {name: "table", wantShown: []string{"IMEI NAME FLEET STATE STORAGE LAST SEEN", "roof", "pilot", "running", "1.2 kB of 57.3 kB", "just now", "-", "workshop", "crashed", "2.5 MB of 8.0 MB", "3 h ago", "unknown", "never"}}, + {name: "table", wantShown: []string{"IMEI NAME FLEET RUN STATE STORAGE LAST SEEN", "roof", "pilot", "running", "1.2 kB of 57.3 kB", "just now", "-", "workshop", "crashed", "2.5 MB of 8.0 MB", "3 h ago", "unknown", "never"}}, {name: "filtered", arguments: []string{"3"}, wantShown: []string{"111111111111111", "333333333333333"}, wantHidden: []string{"222222222222222", "workshop"}}, - {name: "json flag anywhere", arguments: []string{"3", "--json"}, wantShown: []string{`"imei":"111111111111111"`, `"fleet_id":3`}, wantHidden: []string{"LAST SEEN", "222222222222222"}}, + {name: "json flag anywhere", arguments: []string{"3", "--json"}, wantShown: []string{`"imei":"111111111111111"`, `"fleet_id":3`, `"run_state":2`}, wantHidden: []string{"LAST SEEN", "222222222222222", `"reported_state"`}}, {name: "empty fleet", arguments: []string{"5"}, wantExact: "No devices in that fleet.\n"}, - {name: "no devices", devices: `[]`, fleets: `[]`, wantExact: "No devices yet. Claim one with device claim.\n"}, + {name: "no devices", devices: `[]`, fleets: `[]`, wantExact: "No devices yet. Pair one with device pair.\n"}, {name: "server refusal", refusal: "devices unavailable", wantError: "devices unavailable"}, {name: "unknown fleet", arguments: []string{"9"}, wantError: "no such fleet"}, {name: "two ids", arguments: []string{"3", "4"}, wantError: "takes at most one fleet id"}, {name: "wordy id", arguments: []string{"pilot"}, wantError: "shown by fleet list"}, - {name: "an unreadable last seen time leaves the rest of the table", devices: `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":"yesterday"}]`, wantShown: []string{"111111111111111 roof pilot unknown - unknown"}}, - {name: "a fleet the list does not name", devices: `[{"imei":"888888888888888","name":"orphan","fleet_id":99}]`, wantShown: []string{"888888888888888 orphan - unknown - never"}}, + {name: "an unreadable last seen time leaves the rest of the table", devices: `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":"yesterday"}]`, wantShown: []string{"111111111111111 roof pilot unknown - unknown"}}, + {name: "a fleet the list does not name", devices: `[{"imei":"888888888888888","name":"orphan","fleet_id":99}]`, wantShown: []string{"888888888888888 orphan - unknown - never"}}, {name: "a name with control characters is escaped", devices: `[{"imei":"111111111111111","name":"\u001b[2K\rhidden","fleet_id":3}]`, wantShown: []string{`\x1b[2K\rhidden`}, wantHidden: []string{"\x1b"}}, {name: "minutes ago", devices: fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q}]`, now.Add(-12*time.Minute).Format(time.RFC3339)), wantShown: []string{"12 min ago"}}, {name: "days ago", devices: fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q}]`, now.Add(-49*time.Hour).Format(time.RFC3339)), wantShown: []string{"2 d ago"}}, - {name: "stopped and undefined states", devices: `[{"imei":"444444444444444","name":"halted","fleet_id":3,"reported_state":3},{"imei":"555555555555555","name":"odd","fleet_id":3,"reported_state":1}]`, wantShown: []string{"stopped", "unknown"}}, + {name: "stopped and undefined run states", devices: `[{"imei":"444444444444444","name":"halted","fleet_id":3,"reported_state":3},{"imei":"555555555555555","name":"odd","fleet_id":3,"reported_state":1}]`, wantShown: []string{"stopped", "unknown"}}, {name: "byte storage", devices: `[{"imei":"666666666666666","name":"bytes","fleet_id":3,"storage_used":999,"storage_total":999}]`, wantShown: []string{"999 B of 999 B"}}, - {name: "missing used storage", devices: `[{"imei":"777777777777777","name":"nil-used","fleet_id":3,"storage_used":null,"storage_total":57344}]`, wantShown: []string{"777777777777777 nil-used pilot unknown - never"}}, - {name: "missing total storage", devices: `[{"imei":"888888888888888","name":"nil-total","fleet_id":3,"storage_used":1240,"storage_total":null}]`, wantShown: []string{"888888888888888 nil-total pilot unknown - never"}}, + {name: "missing used storage", devices: `[{"imei":"777777777777777","name":"nil-used","fleet_id":3,"storage_used":null,"storage_total":57344}]`, wantShown: []string{"777777777777777 nil-used pilot unknown - never"}}, + {name: "missing total storage", devices: `[{"imei":"888888888888888","name":"nil-total","fleet_id":3,"storage_used":1240,"storage_total":null}]`, wantShown: []string{"888888888888888 nil-total pilot unknown - never"}}, } for _, test := range tests { @@ -223,9 +223,9 @@ func TestDeviceList(t *testing.T) { fmt.Fprint(w, servedDevices) }) mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, servedFleets) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := List(session, test.arguments) + err := List(invocation, test.arguments) printed := out.String() @@ -292,9 +292,9 @@ func TestDeviceRename(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Rename(session, []string{"354820091234567", " pilot "}) + err := Rename(invocation, []string{"354820091234567", " pilot "}) if test.wantError != "" { if err == nil || err.Error() != test.wantError { @@ -331,7 +331,7 @@ func TestDeviceRenameArguments(t *testing.T) { } for _, test := range tests { - err := Rename(api.Session{}, test.arguments) + err := Rename(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) @@ -339,27 +339,27 @@ func TestDeviceRenameArguments(t *testing.T) { } } -func TestDeviceRelease(t *testing.T) { +func TestDeviceUnpair(t *testing.T) { tests := []struct { name string answer string devices string fleets string refusal string - wantReleased bool + wantUnpaired bool wantOutput string wantError string }{ - {name: "confirmed", answer: "yes\n", wantReleased: true, wantOutput: "Release device \"354820091234567\" from fleet \"pilot\"? It wipes the device's files and restarts its code, and claiming it again means pressing its pairing button in person. [y/N] Released device \"354820091234567\" from fleet \"pilot\".\n"}, - {name: "declined", answer: "n\n", wantOutput: "Release device \"354820091234567\" from fleet \"pilot\"? It wipes the device's files and restarts its code, and claiming it again means pressing its pairing button in person. [y/N] Nothing released.\n"}, - {name: "a named device is named back, not its IMEI", answer: "n\n", devices: `[{"imei":"354820091234567","name":"rooftop","fleet_id":3,"last_seen_at":null}]`, wantOutput: "Release device \"rooftop\" from fleet \"pilot\"? It wipes the device's files and restarts its code, and claiming it again means pressing its pairing button in person. [y/N] Nothing released.\n"}, - {name: "server refuses", answer: "y\n", refusal: "no such device", wantReleased: true, wantError: "no such device"}, + {name: "confirmed", answer: "yes\n", wantUnpaired: true, wantOutput: "Unpair device \"354820091234567\" from fleet \"pilot\"? It wipes the device's user files and restarts Lua, and pairing it again means pressing its pairing button in person. [y/N] Unpaired device \"354820091234567\" from fleet \"pilot\".\n"}, + {name: "declined", answer: "n\n", wantOutput: "Unpair device \"354820091234567\" from fleet \"pilot\"? It wipes the device's user files and restarts Lua, and pairing it again means pressing its pairing button in person. [y/N] Nothing unpaired.\n"}, + {name: "a named device is named back, not its IMEI", answer: "n\n", devices: `[{"imei":"354820091234567","name":"rooftop","fleet_id":3,"last_seen_at":null}]`, wantOutput: "Unpair device \"rooftop\" from fleet \"pilot\"? It wipes the device's user files and restarts Lua, and pairing it again means pressing its pairing button in person. [y/N] Nothing unpaired.\n"}, + {name: "server refuses", answer: "y\n", refusal: "no such device", wantUnpaired: true, wantError: "no such device"}, {name: "device belongs to an inaccessible fleet", fleets: `[]`, wantError: "no such device, device list shows yours"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - releasedPath := "" + unpairedPath := "" fleets := test.fleets devices := test.devices @@ -379,7 +379,7 @@ func TestDeviceRelease(t *testing.T) { fmt.Fprint(w, fleets) }) mux.HandleFunc("DELETE /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { - releasedPath = r.URL.Path + unpairedPath = r.URL.Path if test.refusal != "" { http.Error(w, test.refusal, http.StatusNotFound) @@ -389,10 +389,10 @@ func TestDeviceRelease(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) - session.In = strings.NewReader(test.answer) + invocation, out := apitest.LoggedInInvocation(t, mux) + invocation.In = strings.NewReader(test.answer) - err := Release(session, []string{"354820091234567"}) + err := Unpair(invocation, []string{"354820091234567"}) printed := out.String() @@ -408,18 +408,18 @@ func TestDeviceRelease(t *testing.T) { t.Errorf("output = %q", printed) } - if test.wantReleased && releasedPath != "/devices/354820091234567" { - t.Errorf("released path = %q", releasedPath) + if test.wantUnpaired && unpairedPath != "/devices/354820091234567" { + t.Errorf("unpaired path = %q", unpairedPath) } - if !test.wantReleased && releasedPath != "" { - t.Errorf("released path = %q after decline", releasedPath) + if !test.wantUnpaired && unpairedPath != "" { + t.Errorf("unpaired path = %q after decline", unpairedPath) } }) } } -func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { +func TestDeviceUnpairArgumentsAndUnknownDevice(t *testing.T) { tests := []struct { name string arguments []string @@ -432,7 +432,7 @@ func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { } for _, test := range tests { - err := Release(api.Session{}, test.arguments) + err := Unpair(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v", test.name, err) @@ -443,9 +443,9 @@ func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - err := Release(session, []string{"354820091234567"}) + err := Unpair(invocation, []string{"354820091234567"}) if err == nil || err.Error() != "no such device, device list shows yours" { t.Fatalf("error = %v", err) diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index 21d5eac..40b518c 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -13,7 +13,7 @@ type Command struct { Name string Arguments string Summary string - Run func(session api.Session, arguments []string) error + Run func(invocation api.Invocation, arguments []string) error } type Section struct { @@ -58,7 +58,7 @@ func resolve(sections []Section, arguments []string) (Command, []string, bool) { return longest, arguments[longestWords:], true } -func printHelp(session api.Session, sections []Section) { +func printHelp(invocation api.Invocation, sections []Section) { widest := 0 for _, section := range sections { @@ -75,11 +75,11 @@ func printHelp(session api.Session, sections []Section) { } } - fmt.Fprintf(session.Out, "superstack %s\n\n", session.Version) - fmt.Fprint(session.Out, "Usage: superstack [arguments]\n") + fmt.Fprintf(invocation.Out, "superstack %s\n\n", invocation.Version) + fmt.Fprint(invocation.Out, "Usage: superstack [arguments]\n") for _, section := range sections { - fmt.Fprintf(session.Out, "\n%s\n", section.Title) + fmt.Fprintf(invocation.Out, "\n%s\n", section.Title) for _, entry := range section.Commands { signature := entry.Name @@ -88,7 +88,7 @@ func printHelp(session api.Session, sections []Section) { signature += " " + entry.Arguments } - fmt.Fprintf(session.Out, " %-*s %s\n", widest, signature, entry.Summary) + fmt.Fprintf(invocation.Out, " %-*s %s\n", widest, signature, entry.Summary) } } } @@ -124,20 +124,20 @@ func Dispatch(sections []Section, version string, arguments []string, in io.Read arguments = remaining - session := api.NewSession(base, version, in, out) + invocation := api.NewInvocation(base, version, in, out) if len(arguments) == 0 { - printHelp(session, sections) + printHelp(invocation, sections) return nil } switch arguments[0] { case "-h", "--help": - printHelp(session, sections) + printHelp(invocation, sections) return nil case "-v", "--version": - fmt.Fprintln(session.Out, session.Version) + fmt.Fprintln(invocation.Out, invocation.Version) return nil } @@ -149,12 +149,12 @@ func Dispatch(sections []Section, version string, arguments []string, in io.Read switch entry.Name { case "version": - fmt.Fprintln(session.Out, session.Version) + fmt.Fprintln(invocation.Out, invocation.Version) return nil case "help": if len(rest) == 0 { - printHelp(session, sections) + printHelp(invocation, sections) return nil } @@ -170,7 +170,7 @@ func Dispatch(sections []Section, version string, arguments []string, in io.Read signature += " " + topic.Arguments } - fmt.Fprintf(session.Out, "superstack %s\n\n %s\n", signature, topic.Summary) + fmt.Fprintf(invocation.Out, "superstack %s\n\n %s\n", signature, topic.Summary) return nil } @@ -178,7 +178,7 @@ func Dispatch(sections []Section, version string, arguments []string, in io.Read return fmt.Errorf("%s is not available yet", entry.Name) } - err := entry.Run(session, rest) + err := entry.Run(invocation, rest) return err } diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go index 2f2c620..7e62797 100644 --- a/internal/dispatch/dispatch_test.go +++ b/internal/dispatch/dispatch_test.go @@ -90,11 +90,11 @@ func TestDispatchTakesTheServerFlag(t *testing.T) { seenRest := []string{} seenBase := "" - record := func(name string) func(api.Session, []string) error { - return func(session api.Session, arguments []string) error { + record := func(name string) func(api.Invocation, []string) error { + return func(invocation api.Invocation, arguments []string) error { ranCommand = name seenRest = arguments - seenBase = session.Base + seenBase = invocation.Base return nil } @@ -134,7 +134,7 @@ func TestDispatchTakesTheServerFlag(t *testing.T) { } if seenBase != test.wantBase { - t.Errorf("the session base is %q, want %q", seenBase, test.wantBase) + t.Errorf("the invocation base is %q, want %q", seenBase, test.wantBase) } }) } @@ -144,12 +144,12 @@ func TestResolve(t *testing.T) { sections := []Section{{Commands: []Command{ {Name: "login"}, {Name: "device list"}, - {Name: "device claim"}, + {Name: "device pair"}, {Name: "fleet create"}, {Name: "member add"}, - {Name: "key create"}, + {Name: "fleet key create"}, {Name: "account balance"}, - {Name: "account topup"}, + {Name: "account top-up"}, {Name: "upload"}, }}} @@ -161,17 +161,17 @@ func TestResolve(t *testing.T) { }{ {arguments: []string{"login"}, name: "login", rest: []string{}, found: true}, {arguments: []string{"device", "list"}, name: "device list", rest: []string{}, found: true}, - {arguments: []string{"device", "claim", "354820091234567", "sensor-01"}, name: "device claim", rest: []string{"354820091234567", "sensor-01"}, found: true}, + {arguments: []string{"device", "pair", "354820091234567", "sensor-01"}, name: "device pair", rest: []string{"354820091234567", "sensor-01"}, found: true}, {arguments: []string{"fleet", "create", "thermostats"}, name: "fleet create", rest: []string{"thermostats"}, found: true}, {arguments: []string{"member", "add", "member@example.com"}, name: "member add", rest: []string{"member@example.com"}, found: true}, - {arguments: []string{"key", "create", "42", "production"}, name: "key create", rest: []string{"42", "production"}, found: true}, + {arguments: []string{"fleet", "key", "create", "42", "production"}, name: "fleet 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{"account", "top-up", "42"}, name: "account top-up", 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}, {arguments: []string{"device"}, found: false}, - {arguments: []string{"key"}, found: false}, + {arguments: []string{"fleet", "key"}, found: false}, {arguments: []string{"account"}, found: false}, {arguments: []string{"deploy"}, found: false}, {arguments: []string{}, found: false}, @@ -202,8 +202,8 @@ func TestResolve(t *testing.T) { func TestDispatch(t *testing.T) { sections := []Section{ {Title: "Things", Commands: []Command{ - {Name: "thing list", Arguments: "", Summary: "List a thing", Run: func(session api.Session, arguments []string) error { - fmt.Fprintln(session.Out, "Listed.") + {Name: "thing list", Arguments: "", Summary: "List a thing", Run: func(invocation api.Invocation, arguments []string) error { + fmt.Fprintln(invocation.Out, "Listed.") return nil }}, {Name: "thing pending", Summary: "Wait for a thing"}, @@ -268,9 +268,9 @@ func TestHelpListsEveryCommand(t *testing.T) { {Title: "Account", Commands: []Command{{Name: "account delete", Summary: "Delete the account"}}}, } out := &bytes.Buffer{} - session := api.NewSession(api.DefaultBase, "1.2.3", strings.NewReader(""), out) + invocation := api.NewInvocation(api.DefaultBase, "1.2.3", strings.NewReader(""), out) - printHelp(session, sections) + printHelp(invocation, sections) for _, section := range sections { if !strings.Contains(out.String(), section.Title) { diff --git a/internal/fleet/fleet.go b/internal/fleet/fleet.go index 5fbca86..226ced5 100644 --- a/internal/fleet/fleet.go +++ b/internal/fleet/fleet.go @@ -13,7 +13,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func Create(session api.Session, arguments []string) error { +func Create(invocation api.Invocation, arguments []string) error { if len(arguments) != 1 || arguments[0] == "" { return errors.New("fleet create takes one name, quoted if it has spaces") } @@ -24,7 +24,7 @@ func Create(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPost, "/fleets", bytes.NewReader(body)) + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/fleets", bytes.NewReader(body)) if err != nil { return err @@ -32,10 +32,10 @@ func Create(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -55,32 +55,32 @@ func Create(session api.Session, arguments []string) error { return err } - fmt.Fprintf(session.Out, "Created fleet %q with id %d.\n", created.Name, created.Id) + fmt.Fprintf(invocation.Out, "Created fleet %q with id %d.\n", created.Name, created.Id) return nil } -func List(session api.Session, arguments []string) error { +func List(invocation api.Invocation, arguments []string) error { positionals, jsonOutput := api.TakeJsonFlag(arguments) if len(positionals) != 0 { return errors.New("fleet list takes no arguments") } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err } if jsonOutput { - err = json.NewEncoder(session.Out).Encode(fleets) + err = json.NewEncoder(invocation.Out).Encode(fleets) return err } if len(fleets) == 0 { - fmt.Fprintln(session.Out, "No fleets yet. Create one with fleet create.") + fmt.Fprintln(invocation.Out, "No fleets yet. Create one with fleet create.") return nil } @@ -94,7 +94,7 @@ func List(session api.Session, arguments []string) error { nameWidth = max(nameWidth, len(nameValues[index])) } - fmt.Fprintf(session.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "ROLE") + fmt.Fprintf(invocation.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "ROLE") for index, fleet := range fleets { role := "member" @@ -103,13 +103,13 @@ func List(session api.Session, arguments []string) error { role = "owner" } - fmt.Fprintf(session.Out, "%-*d %-*s %s\n", idWidth, fleet.Id, nameWidth, nameValues[index], role) + fmt.Fprintf(invocation.Out, "%-*d %-*s %s\n", idWidth, fleet.Id, nameWidth, nameValues[index], role) } return nil } -func Rename(session api.Session, arguments []string) error { +func Rename(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 { return errors.New("fleet rename takes a fleet id and a new name, quoted if it has spaces") } @@ -132,7 +132,7 @@ func Rename(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPatch, + request, err := api.AuthenticatedRequest(invocation, http.MethodPatch, "/fleets/"+strconv.FormatInt(fleetId, 10), bytes.NewReader(body)) if err != nil { @@ -141,10 +141,10 @@ func Rename(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -153,12 +153,12 @@ func Rename(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Renamed fleet %d to %q.\n", fleetId, name) + fmt.Fprintf(invocation.Out, "Renamed fleet %d to %q.\n", fleetId, name) return nil } -func Transfer(session api.Session, arguments []string) error { +func Transfer(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 || arguments[1] == "" { return errors.New("fleet transfer takes a fleet id and an email address") } @@ -171,7 +171,7 @@ func Transfer(session api.Session, arguments []string) error { email := arguments[1] - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -191,14 +191,14 @@ func Transfer(session api.Session, arguments []string) error { return errors.New("no such fleet") } - fmt.Fprintf(session.Out, "Hand fleet %q to %s? They become the owner, and you lose access to the fleet, its devices and its credit. [y/N] ", name, email) + fmt.Fprintf(invocation.Out, "Hand fleet %q to %s? They become the owner, and you lose access to the fleet, its devices and its credit. [y/N] ", name, email) - answer, _ := bufio.NewReader(session.In).ReadString('\n') + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') answer = strings.ToLower(strings.TrimSpace(answer)) if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing transferred.") + fmt.Fprintln(invocation.Out, "Nothing transferred.") return nil } @@ -208,7 +208,7 @@ func Transfer(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPost, + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/owner", bytes.NewReader(body)) if err != nil { @@ -217,10 +217,10 @@ func Transfer(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -229,12 +229,12 @@ func Transfer(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Transferred fleet %q to %s.\n", name, email) + fmt.Fprintf(invocation.Out, "Transferred fleet %q to %s.\n", name, email) return nil } -func Delete(session api.Session, arguments []string) error { +func Delete(invocation api.Invocation, arguments []string) error { if len(arguments) != 1 { return errors.New("fleet delete takes a fleet id") } @@ -245,7 +245,7 @@ func Delete(session api.Session, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -265,7 +265,7 @@ func Delete(session api.Session, arguments []string) error { return errors.New("no such fleet") } - balances, err := api.FetchBalances(session) + balances, err := api.FetchBalances(invocation) if err != nil { return err @@ -291,36 +291,36 @@ func Delete(session api.Session, arguments []string) error { } } - consequence := "It wipes their files and restarts their code, and claiming one again means pressing its pairing button in person." + consequence := "It wipes their user files and restarts Lua, and pairing one again means pressing its pairing button in person." if forfeitUnknown { - fmt.Fprintf(session.Out, "Delete fleet %q, release its devices, and forfeit its remaining credit? %s [y/N] ", name, consequence) + fmt.Fprintf(invocation.Out, "Delete fleet %q, unpair its devices, and forfeit its remaining credit? %s [y/N] ", name, consequence) } else if forfeited == "" { - fmt.Fprintf(session.Out, "Delete fleet %q and release its devices? %s [y/N] ", name, consequence) + fmt.Fprintf(invocation.Out, "Delete fleet %q and unpair its devices? %s [y/N] ", name, consequence) } else { - fmt.Fprintf(session.Out, "Delete fleet %q, release its devices, and forfeit its remaining %s of credit? %s [y/N] ", name, forfeited, consequence) + fmt.Fprintf(invocation.Out, "Delete fleet %q, unpair its devices, and forfeit its remaining %s of credit? %s [y/N] ", name, forfeited, consequence) } - answer, _ := bufio.NewReader(session.In).ReadString('\n') + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') answer = strings.ToLower(strings.TrimSpace(answer)) if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing deleted.") + fmt.Fprintln(invocation.Out, "Nothing deleted.") return nil } - request, err := api.AuthenticatedRequest(session, http.MethodDelete, + request, err := api.AuthenticatedRequest(invocation, http.MethodDelete, "/fleets/"+strconv.FormatInt(fleetId, 10), nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -329,7 +329,7 @@ func Delete(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Deleted fleet %q.\n", name) + fmt.Fprintf(invocation.Out, "Deleted fleet %q.\n", name) return nil } diff --git a/internal/fleet/fleet_test.go b/internal/fleet/fleet_test.go index fd8f3e5..7f8c7d3 100644 --- a/internal/fleet/fleet_test.go +++ b/internal/fleet/fleet_test.go @@ -43,9 +43,9 @@ func TestFleetCreate(t *testing.T) { fmt.Fprintf(w, `{"id": 5, "name": %q}`, body.Name) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Create(session, []string{"field trial"}) + err := Create(invocation, []string{"field trial"}) if test.wantError != "" { if err == nil || err.Error() != test.wantError { @@ -77,7 +77,7 @@ func TestFleetCreateTakesOneName(t *testing.T) { } for _, test := range tests { - err := Create(api.Session{}, test.arguments) + err := Create(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), "takes one name") { t.Errorf("%s: error = %v, want the one-name hint", test.name, err) @@ -144,9 +144,9 @@ func TestFleetList(t *testing.T) { fmt.Fprint(w, test.fleets) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := List(session, test.arguments) + err := List(invocation, test.arguments) printed := out.String() @@ -215,9 +215,9 @@ func TestFleetRename(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Rename(session, []string{"9", " pilot "}) + err := Rename(invocation, []string{"9", " pilot "}) if test.wantError != "" { if err == nil || err.Error() != test.wantError { @@ -254,7 +254,7 @@ func TestFleetRenameArguments(t *testing.T) { } for _, test := range tests { - err := Rename(api.Session{}, test.arguments) + err := Rename(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) @@ -334,11 +334,11 @@ func TestFleetTransfer(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader(test.answer) + invocation.In = strings.NewReader(test.answer) - err := Transfer(session, []string{"3", "successor@example.com"}) + err := Transfer(invocation, []string{"3", "successor@example.com"}) printed := out.String() @@ -383,7 +383,7 @@ func TestFleetTransferArguments(t *testing.T) { } for _, test := range tests { - err := Transfer(api.Session{}, test.arguments) + err := Transfer(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) @@ -438,11 +438,11 @@ func TestFleetDelete(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader(test.answer) + invocation.In = strings.NewReader(test.answer) - err := Delete(session, []string{"3"}) + err := Delete(invocation, []string{"3"}) printed := out.String() @@ -485,18 +485,18 @@ func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { { name: "remaining credit is stated", balance: `[{"fleet":3,"balance":"12.340000","currency":"eur"}]`, - wantOutput: "Delete fleet \"pilot\", release its devices, and forfeit its remaining €12.34 of credit? It wipes their files and restarts their code, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + wantOutput: "Delete fleet \"pilot\", unpair its devices, and forfeit its remaining €12.34 of credit? It wipes their user files and restarts Lua, and pairing one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", }, { name: "an empty balance stays quiet", balance: `[{"fleet":3,"balance":"0","currency":"eur"}]`, - wantOutput: "Delete fleet \"pilot\" and release its devices? It wipes their files and restarts their code, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + wantOutput: "Delete fleet \"pilot\" and unpair its devices? It wipes their user files and restarts Lua, and pairing one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", wantAbsent: "forfeit", }, { name: "an unparseable balance warns without an amount", balance: `[{"fleet":3,"balance":"15,00","currency":"eur"}]`, - wantOutput: "Delete fleet \"pilot\", release its devices, and forfeit its remaining credit? It wipes their files and restarts their code, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + wantOutput: "Delete fleet \"pilot\", unpair its devices, and forfeit its remaining credit? It wipes their user files and restarts Lua, and pairing one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", wantAbsent: "€", }, } @@ -513,11 +513,11 @@ func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { fmt.Fprint(w, test.balance) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader("n\n") + invocation.In = strings.NewReader("n\n") - err := Delete(session, []string{"3"}) + err := Delete(invocation, []string{"3"}) printed := out.String() @@ -529,8 +529,8 @@ func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { t.Errorf("output = %q, want %q", printed, test.wantOutput) } - if !strings.Contains(printed, "It wipes their files and restarts their code, and claiming one again means pressing its pairing button in person.") { - t.Errorf("the prompt %q does not say what releasing the devices does to them", printed) + if !strings.Contains(printed, "It wipes their user files and restarts Lua, and pairing one again means pressing its pairing button in person.") { + t.Errorf("the prompt %q does not say what unpairing the devices does to them", printed) } if test.wantAbsent != "" && strings.Contains(printed, test.wantAbsent) { @@ -559,11 +559,11 @@ func TestFleetDeleteRefusesWhenTheBalanceIsUnknown(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader("y\n") + invocation.In = strings.NewReader("y\n") - err := Delete(session, []string{"3"}) + err := Delete(invocation, []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) @@ -581,9 +581,9 @@ func TestFleetDeleteUnknownFleet(t *testing.T) { fmt.Fprint(w, `[]`) }) - session, _ := apitest.LoggedInSession(t, mux) + invocation, _ := apitest.LoggedInInvocation(t, mux) - err := Delete(session, []string{"9"}) + err := Delete(invocation, []string{"9"}) if err == nil || !strings.Contains(err.Error(), "no such fleet") { t.Fatalf("error = %v, want no such fleet", err) @@ -602,7 +602,7 @@ func TestFleetDeleteArguments(t *testing.T) { } for _, test := range tests { - err := Delete(api.Session{}, test.arguments) + err := Delete(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) diff --git a/internal/fleetkey/fleetkey.go b/internal/fleetkey/fleetkey.go new file mode 100644 index 0000000..f27d3f8 --- /dev/null +++ b/internal/fleetkey/fleetkey.go @@ -0,0 +1,240 @@ +package fleetkey + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" +) + +func Create(invocation api.Invocation, arguments []string) error { + if len(arguments) != 2 || arguments[1] == "" { + return errors.New("fleet key create takes a fleet id and a label, quoted if it has spaces") + } + + 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") + } + + body, err := json.Marshal(map[string]string{"label": arguments[1]}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/keys", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := invocation.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + created := struct { + Id int64 `json:"id"` + FleetKey string `json:"key"` + }{} + + err = api.Decode(response, &created) + + if err != nil { + return err + } + + if created.FleetKey == "" { + return errors.New("the fleet key was not created, try again") + } + + fmt.Fprintf(invocation.Out, "Created fleet key %d.\n\n %s\n\nAnyone holding it can send data to the fleet, and you will not see it again.\n", created.Id, api.Printable(created.FleetKey)) + + return nil +} + +func List(invocation api.Invocation, arguments []string) error { + positionals, jsonOutput := api.TakeJsonFlag(arguments) + + if len(positionals) > 1 { + return errors.New("fleet key list 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 := api.FetchFleets(invocation) + + 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 := api.FetchFleetKeys(invocation) + + if err != nil { + return err + } + + fleetKeys := []api.FleetKeyEntry{} + + for _, fleetKey := range fetched { + if chosenFleetId == 0 || fleetKey.Fleet == chosenFleetId { + fleetKeys = append(fleetKeys, fleetKey) + } + } + + if jsonOutput { + err = json.NewEncoder(invocation.Out).Encode(fleetKeys) + + return err + } + + if len(fleetKeys) == 0 { + if chosenFleetId == 0 { + fmt.Fprintln(invocation.Out, "No fleet keys yet. Create one with fleet key create.") + } else { + fmt.Fprintln(invocation.Out, "No fleet keys on that fleet yet.") + } + + return nil + } + + idWidth := len("ID") + fleetIdWidth := len("FLEET") + fleetNameWidth := len("FLEET NAME") + fleetKeyWidth := len("FLEET KEY") + fleetNameValues := make([]string, len(fleetKeys)) + suffixValues := make([]string, len(fleetKeys)) + labelValues := make([]string, len(fleetKeys)) + + for index, fleetKey := range fleetKeys { + fleetName, known := fleetNames[fleetKey.Fleet] + + if !known { + fleetName = "-" + } + + fleetNameValues[index] = api.Printable(fleetName) + suffixValues[index] = api.Printable(fleetKey.Suffix) + labelValues[index] = api.Printable(fleetKey.Label) + idWidth = max(idWidth, len(strconv.FormatInt(fleetKey.Id, 10))) + fleetIdWidth = max(fleetIdWidth, len(strconv.FormatInt(fleetKey.Fleet, 10))) + fleetNameWidth = max(fleetNameWidth, len(fleetNameValues[index])) + } + + fmt.Fprintf(invocation.Out, "%-*s %-*s %-*s %-*s %s\n", + idWidth, "ID", fleetIdWidth, "FLEET", fleetNameWidth, "FLEET NAME", fleetKeyWidth, "FLEET KEY", "LABEL") + + for index, fleetKey := range fleetKeys { + fmt.Fprintf(invocation.Out, "%-*d %-*d %-*s %-*s %s\n", + idWidth, fleetKey.Id, fleetIdWidth, fleetKey.Fleet, fleetNameWidth, fleetNameValues[index], + fleetKeyWidth, "..."+suffixValues[index], labelValues[index]) + } + + return nil +} + +func Revoke(invocation api.Invocation, arguments []string) error { + if len(arguments) != 1 { + return errors.New("fleet key revoke takes a fleet key id") + } + + fleetKeyId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetKeyId < 1 { + return errors.New("the fleet key id is the number shown by fleet key list") + } + + fleetKeys, err := api.FetchFleetKeys(invocation) + + if err != nil { + return err + } + + label := "" + found := false + + for _, fleetKey := range fleetKeys { + if fleetKey.Id == fleetKeyId { + label = fleetKey.Label + found = true + } + } + + if !found { + return errors.New("no such fleet key") + } + + fmt.Fprintf(invocation.Out, "Revoke fleet key %q? Anything still using it stops reaching the fleet. [y/N] ", label) + + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(invocation.Out, "Nothing revoked.") + return nil + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodDelete, + "/keys/"+strconv.FormatInt(fleetKeyId, 10), nil) + + if err != nil { + return err + } + + response, err := invocation.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(invocation.Out, "Revoked fleet key %q.\n", label) + + return nil +} diff --git a/internal/key/key_test.go b/internal/fleetkey/fleetkey_test.go similarity index 79% rename from internal/key/key_test.go rename to internal/fleetkey/fleetkey_test.go index 1ac1ba9..6378a7a 100644 --- a/internal/key/key_test.go +++ b/internal/fleetkey/fleetkey_test.go @@ -1,4 +1,4 @@ -package key +package fleetkey import ( "encoding/json" @@ -10,7 +10,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api/apitest" ) -func TestKeyCreate(t *testing.T) { +func TestFleetKeyCreate(t *testing.T) { tests := []struct { name string arguments []string @@ -21,7 +21,7 @@ func TestKeyCreate(t *testing.T) { wantError string }{ { - name: "the server answers without a key", + name: "the server answers without a fleet key", arguments: []string{"3", "deploy server"}, wantPath: "/fleets/3/keys", wantLabel: "deploy server", @@ -29,7 +29,7 @@ func TestKeyCreate(t *testing.T) { wantError: "was not created", }, { - name: "a labelled key", + name: "a labelled fleet key", arguments: []string{"3", "deploy server"}, wantPath: "/fleets/3/keys", wantLabel: "deploy server", @@ -105,9 +105,9 @@ func TestKeyCreate(t *testing.T) { fmt.Fprint(w, answer) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Create(session, test.arguments) + err := Create(invocation, test.arguments) printed := out.String() @@ -124,11 +124,11 @@ func TestKeyCreate(t *testing.T) { } if !strings.Contains(printed, "ssf_testtesttestab2de") { - t.Errorf("the output %q does not show the key", printed) + t.Errorf("the output %q does not show the fleet key", printed) } if !strings.Contains(printed, "you will not see it again") { - t.Errorf("the output %q does not warn that the key cannot be shown again", printed) + t.Errorf("the output %q does not warn that the fleet key cannot be shown again", printed) } if !strings.Contains(printed, "Created fleet key 1.") { @@ -138,12 +138,12 @@ func TestKeyCreate(t *testing.T) { } } -func TestKeyList(t *testing.T) { +func TestFleetKeyList(t *testing.T) { fleets := `[{"id":3,"name":"crew","owner":true},` + `{"id":4,"name":"skunkworks","owner":false},` + `{"id":5,"name":"spares","owner":true}]` - keys := `[{"id":1,"fleet":3,"label":"deploy server","suffix":"ab2de"},` + + fleetKeys := `[{"id":1,"fleet":3,"label":"deploy server","suffix":"ab2de"},` + `{"id":2,"fleet":4,"label":"lab sensor","suffix":"f9hjk"}]` tests := []struct { @@ -152,22 +152,22 @@ func TestKeyList(t *testing.T) { wantShown []string wantHidden []string wantError string - keys string + fleetKeys string refusal string }{ { name: "server refusal", arguments: []string{}, - refusal: "keys unavailable", - wantError: "keys unavailable", + refusal: "fleet keys unavailable", + wantError: "fleet keys unavailable", }, { - name: "every fleet's keys", + name: "every fleet's fleet keys", arguments: []string{}, - wantShown: []string{"ID FLEET FLEET NAME", "crew", "skunkworks", "...ab2de", "...f9hjk", "deploy server", "lab sensor"}, + wantShown: []string{"ID FLEET FLEET NAME FLEET KEY", "crew", "skunkworks", "...ab2de", "...f9hjk", "deploy server", "lab sensor"}, }, { - name: "one fleet's keys", + name: "one fleet's fleet keys", arguments: []string{"3"}, wantShown: []string{"ID FLEET FLEET NAME", "crew", "...ab2de"}, wantHidden: []string{"skunkworks", "f9hjk", "lab sensor"}, @@ -175,18 +175,18 @@ func TestKeyList(t *testing.T) { { name: "a label with control characters is escaped", arguments: []string{}, - keys: `[{"id":1,"fleet":3,"label":"\u001b[2Kquiet","suffix":"ab2de"}]`, + fleetKeys: `[{"id":1,"fleet":3,"label":"\u001b[2Kquiet","suffix":"ab2de"}]`, wantShown: []string{`\x1b[2Kquiet`}, wantHidden: []string{"\x1b"}, }, { - name: "no keys", + name: "no fleet keys", arguments: []string{}, - wantShown: []string{"No fleet keys yet. Create one with key create."}, - keys: `[]`, + wantShown: []string{"No fleet keys yet. Create one with fleet key create."}, + fleetKeys: `[]`, }, { - name: "a fleet without keys", + name: "a fleet without fleet keys", arguments: []string{"5"}, wantShown: []string{"No fleet keys on that fleet yet."}, wantHidden: []string{"ID FLEET"}, @@ -206,7 +206,7 @@ func TestKeyList(t *testing.T) { { name: "a fleet the list does not name", arguments: []string{}, - keys: `[{"id":1,"fleet":99,"label":"orphan","suffix":"ab2de"}]`, + fleetKeys: `[{"id":1,"fleet":99,"label":"orphan","suffix":"ab2de"}]`, wantShown: []string{"99 -"}, }, { @@ -228,10 +228,10 @@ func TestKeyList(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - servedKeys := test.keys + servedFleetKeys := test.fleetKeys - if servedKeys == "" { - servedKeys = keys + if servedFleetKeys == "" { + servedFleetKeys = fleetKeys } mux := http.NewServeMux() @@ -246,12 +246,12 @@ func TestKeyList(t *testing.T) { return } - fmt.Fprint(w, servedKeys) + fmt.Fprint(w, servedFleetKeys) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := List(session, test.arguments) + err := List(invocation, test.arguments) printed := out.String() @@ -282,7 +282,7 @@ func TestKeyList(t *testing.T) { } } -func TestKeyRevoke(t *testing.T) { +func TestFleetKeyRevoke(t *testing.T) { tests := []struct { name string arguments []string @@ -293,7 +293,7 @@ func TestKeyRevoke(t *testing.T) { wantError string }{ { - name: "revoke a key", + name: "revoke a fleet key", arguments: []string{"3"}, answer: "y\n", wantRevoked: "/keys/3", @@ -320,30 +320,30 @@ func TestKeyRevoke(t *testing.T) { name: "the server refuses after the confirmation", arguments: []string{"3"}, answer: "y\n", - refusal: "no such key", + refusal: "no such fleet key", wantRevoked: "/keys/3", - wantError: "no such key", + wantError: "no such fleet key", }, { - name: "a key that is not yours", + name: "a fleet key that is not yours", arguments: []string{"9"}, answer: "y\n", - wantError: "no such key", + wantError: "no such fleet key", }, { - name: "no key id", + name: "no fleet key id", arguments: []string{}, - wantError: "takes a key id", + wantError: "takes a fleet key id", }, { - name: "two key ids", + name: "two fleet key ids", arguments: []string{"3", "4"}, - wantError: "takes a key id", + wantError: "takes a fleet key id", }, { name: "a wordy id", arguments: []string{"pilot"}, - wantError: "shown by key list", + wantError: "shown by fleet key list", }, } @@ -368,10 +368,10 @@ func TestKeyRevoke(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) - session.In = strings.NewReader(test.answer) + invocation, out := apitest.LoggedInInvocation(t, mux) + invocation.In = strings.NewReader(test.answer) - err := Revoke(session, test.arguments) + err := Revoke(invocation, test.arguments) printed := out.String() diff --git a/internal/key/key.go b/internal/key/key.go deleted file mode 100644 index 81ce208..0000000 --- a/internal/key/key.go +++ /dev/null @@ -1,240 +0,0 @@ -package key - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/siliconwitchery/superstack-cli/internal/api" -) - -func Create(session api.Session, arguments []string) error { - if len(arguments) != 2 || arguments[1] == "" { - return errors.New("key create takes a fleet id and a label, quoted if it has spaces") - } - - 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") - } - - body, err := json.Marshal(map[string]string{"label": arguments[1]}) - - if err != nil { - return err - } - - request, err := api.AuthenticatedRequest(session, http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/keys", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := session.Client.Do(request) - - if err != nil { - return errors.New("the server could not be reached, check your connection") - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return api.ServerError(response) - } - - created := struct { - Id int64 `json:"id"` - Key string `json:"key"` - }{} - - err = api.Decode(response, &created) - - if err != nil { - return err - } - - if created.Key == "" { - return errors.New("the fleet key was not created, try again") - } - - fmt.Fprintf(session.Out, "Created fleet key %d.\n\n %s\n\nAnyone holding it can send data to the fleet, and you will not see it again.\n", created.Id, api.Printable(created.Key)) - - return nil -} - -func List(session api.Session, arguments []string) error { - positionals, jsonOutput := api.TakeJsonFlag(arguments) - - if len(positionals) > 1 { - return errors.New("key list 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 := api.FetchFleets(session) - - 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 := api.FetchKeys(session) - - if err != nil { - return err - } - - keys := []api.KeyEntry{} - - for _, key := range fetched { - if chosenFleetId == 0 || key.Fleet == chosenFleetId { - keys = append(keys, key) - } - } - - if jsonOutput { - err = json.NewEncoder(session.Out).Encode(keys) - - return err - } - - if len(keys) == 0 { - if chosenFleetId == 0 { - fmt.Fprintln(session.Out, "No fleet keys yet. Create one with key create.") - } else { - fmt.Fprintln(session.Out, "No fleet keys on that fleet yet.") - } - - return nil - } - - idWidth := len("ID") - fleetIdWidth := len("FLEET") - fleetNameWidth := len("FLEET NAME") - fleetNameValues := make([]string, len(keys)) - suffixValues := make([]string, len(keys)) - labelValues := make([]string, len(keys)) - - for index, key := range keys { - fleetName, known := fleetNames[key.Fleet] - - if !known { - fleetName = "-" - } - - fleetNameValues[index] = api.Printable(fleetName) - suffixValues[index] = api.Printable(key.Suffix) - labelValues[index] = api.Printable(key.Label) - idWidth = max(idWidth, len(strconv.FormatInt(key.Id, 10))) - fleetIdWidth = max(fleetIdWidth, len(strconv.FormatInt(key.Fleet, 10))) - fleetNameWidth = max(fleetNameWidth, len(fleetNameValues[index])) - } - - // KEY is fixed at eight: the server sends a five-character suffix and the - // cell prefixes it with three dots, so a shorter suffix would skew LABEL. - fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-8s %s\n", - idWidth, "ID", fleetIdWidth, "FLEET", fleetNameWidth, "FLEET NAME", "KEY", "LABEL") - - for index, key := range keys { - fmt.Fprintf(session.Out, "%-*d %-*d %-*s ...%s %s\n", - idWidth, key.Id, fleetIdWidth, key.Fleet, fleetNameWidth, fleetNameValues[index], suffixValues[index], labelValues[index]) - } - - return nil -} - -func Revoke(session api.Session, arguments []string) error { - if len(arguments) != 1 { - return errors.New("key revoke takes a key id") - } - - keyId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || keyId < 1 { - return errors.New("the key id is the number shown by key list") - } - - keys, err := api.FetchKeys(session) - - if err != nil { - return err - } - - label := "" - found := false - - for _, key := range keys { - if key.Id == keyId { - label = key.Label - found = true - } - } - - if !found { - return errors.New("no such key") - } - - fmt.Fprintf(session.Out, "Revoke fleet key %q? Anything still using it stops reaching the fleet. [y/N] ", label) - - answer, _ := bufio.NewReader(session.In).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing revoked.") - return nil - } - - request, err := api.AuthenticatedRequest(session, http.MethodDelete, - "/keys/"+strconv.FormatInt(keyId, 10), nil) - - if err != nil { - return err - } - - response, err := session.Client.Do(request) - - if err != nil { - return errors.New("the server could not be reached, check your connection") - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - return api.ServerError(response) - } - - fmt.Fprintf(session.Out, "Revoked fleet key %q.\n", label) - - return nil -} diff --git a/internal/login/login.go b/internal/login/login.go index 5c135c7..d19e8df 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -17,23 +17,23 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func Login(session api.Session, arguments []string) error { +func Login(invocation api.Invocation, arguments []string) error { if len(arguments) != 1 || (arguments[0] != "github" && arguments[0] != "gitlab") { return errors.New("login takes a provider: github or gitlab") } provider := arguments[0] - providersRequest, err := api.Request(session, http.MethodGet, "/login", nil) + providersRequest, err := api.Request(invocation, http.MethodGet, "/login", nil) if err != nil { return err } - providersResponse, err := session.Client.Do(providersRequest) + providersResponse, err := invocation.Client.Do(providersRequest) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer providersResponse.Body.Close() @@ -58,14 +58,14 @@ func Login(session api.Session, arguments []string) error { switch provider { case "github": clientId = providers.GithubClientId - deviceCodeUrl = session.GithubBase + "/login/device/code" - pollUrl = session.GithubBase + "/login/oauth/access_token" + deviceCodeUrl = invocation.GithubBase + "/login/device/code" + pollUrl = invocation.GithubBase + "/login/oauth/access_token" scope = "user:email" case "gitlab": clientId = providers.GitlabClientId - deviceCodeUrl = session.GitlabBase + "/oauth/authorize_device" - pollUrl = session.GitlabBase + "/oauth/token" + deviceCodeUrl = invocation.GitlabBase + "/oauth/authorize_device" + pollUrl = invocation.GitlabBase + "/oauth/token" scope = "read_user" } @@ -93,7 +93,7 @@ func Login(session api.Session, arguments []string) error { codeResponse, err := oauthClient.Do(codeRequest) if err != nil { - return fmt.Errorf("%s could not be reached, check your connection", provider) + return fmt.Errorf("%s could not be reached, check your internet access", provider) } defer codeResponse.Body.Close() @@ -124,15 +124,15 @@ func Login(session api.Session, arguments []string) error { enterAt = code.VerificationUriComplete } - fmt.Fprintf(session.Out, "Copy your one-time code: %s\n", api.Printable(code.UserCode)) - fmt.Fprintf(session.Out, "Then enter it at %s\n", api.Printable(enterAt)) - fmt.Fprintln(session.Out, "Press enter to open the browser.") + fmt.Fprintf(invocation.Out, "Copy your one-time code: %s\n", api.Printable(code.UserCode)) + fmt.Fprintf(invocation.Out, "Then enter it at %s\n", api.Printable(enterAt)) + fmt.Fprintln(invocation.Out, "Press enter to open the browser.") go func() { - _, err := bufio.NewReader(session.In).ReadString('\n') + _, err := bufio.NewReader(invocation.In).ReadString('\n') if err == nil { - session.OpenBrowser(enterAt) + invocation.OpenBrowser(enterAt) } }() @@ -144,9 +144,9 @@ func Login(session api.Session, arguments []string) error { interval = 5 // RFC 8628 section 3.2: the default when a provider omits it } - accessToken := "" + providerAccessToken := "" - for accessToken == "" { + for providerAccessToken == "" { time.Sleep(time.Duration(interval) * time.Second) if time.Now().After(deadline) { @@ -172,12 +172,12 @@ func Login(session api.Session, arguments []string) error { pollResponse, err := oauthClient.Do(pollRequest) if err != nil { - return fmt.Errorf("%s could not be reached, check your connection", provider) + return fmt.Errorf("%s could not be reached, check your internet access", provider) } poll := struct { - AccessToken string `json:"access_token"` - Error string `json:"error"` + ProviderAccessToken string `json:"access_token"` + Error string `json:"error"` }{} err = api.Decode(pollResponse, &poll) @@ -190,11 +190,11 @@ func Login(session api.Session, arguments []string) error { switch poll.Error { case "": - if poll.AccessToken == "" { + if poll.ProviderAccessToken == "" { return fmt.Errorf("the login did not complete on %s, run login again", provider) } - accessToken = poll.AccessToken + providerAccessToken = poll.ProviderAccessToken case "authorization_pending": @@ -214,14 +214,14 @@ func Login(session api.Session, arguments []string) error { loginBody, err := json.Marshal(map[string]string{ "provider": provider, - "access_token": accessToken, + "access_token": providerAccessToken, }) if err != nil { return err } - loginRequest, err := api.Request(session, http.MethodPost, "/login", bytes.NewReader(loginBody)) + loginRequest, err := api.Request(invocation, http.MethodPost, "/login", bytes.NewReader(loginBody)) if err != nil { return err @@ -229,10 +229,10 @@ func Login(session api.Session, arguments []string) error { loginRequest.Header.Set("Content-Type", "application/json") - loginResponse, err := session.Client.Do(loginRequest) + loginResponse, err := invocation.Client.Do(loginRequest) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer loginResponse.Body.Close() @@ -242,8 +242,8 @@ func Login(session api.Session, arguments []string) error { } login := struct { - Key string `json:"key"` - Email string `json:"email"` + LoginKey string `json:"key"` + Email string `json:"email"` }{} err = api.Decode(loginResponse, &login) @@ -252,11 +252,11 @@ func Login(session api.Session, arguments []string) error { return err } - if login.Key == "" { + if login.LoginKey == "" { return errors.New("the login did not complete") } - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { return err @@ -270,7 +270,7 @@ func Login(session api.Session, arguments []string) error { return fmt.Errorf("the login could not be saved to %s, so you are not logged in", path) } - temporary, err := os.CreateTemp(directory, "key") + temporary, err := os.CreateTemp(directory, "login-key") if err != nil { return fmt.Errorf("the login could not be saved to %s, so you are not logged in", path) @@ -278,7 +278,7 @@ func Login(session api.Session, arguments []string) error { defer os.Remove(temporary.Name()) - _, err = temporary.WriteString(login.Key + "\n") + _, err = temporary.WriteString(login.LoginKey + "\n") if err != nil { temporary.Close() @@ -297,26 +297,26 @@ func Login(session api.Session, arguments []string) error { return fmt.Errorf("the login could not be saved to %s, so you are not logged in", path) } - fmt.Fprintf(session.Out, "Logged in as %s.\n", api.Printable(login.Email)) + fmt.Fprintf(invocation.Out, "Logged in as %s.\n", api.Printable(login.Email)) return nil } -func Logout(session api.Session, arguments []string) error { +func Logout(invocation api.Invocation, arguments []string) error { if len(arguments) != 0 { return errors.New("logout takes no arguments") } - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { return err } - keyBytes, err := os.ReadFile(path) + loginKeyBytes, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { - fmt.Fprintln(session.Out, "Not logged in.") + fmt.Fprintln(invocation.Out, "Not logged in.") return nil } @@ -324,22 +324,22 @@ func Logout(session api.Session, arguments []string) error { return errors.New("the login stored on this computer could not be read") } - key := strings.TrimSpace(string(keyBytes)) + loginKey := strings.TrimSpace(string(loginKeyBytes)) - if key == "" { - fmt.Fprintln(session.Out, "Not logged in.") + if loginKey == "" { + fmt.Fprintln(invocation.Out, "Not logged in.") return nil } - revokeRequest, err := api.Request(session, http.MethodPost, "/logout", nil) + revokeRequest, err := api.Request(invocation, http.MethodPost, "/logout", nil) if err != nil { return err } - revokeRequest.Header.Set("Authorization", "Bearer "+key) + revokeRequest.Header.Set("Authorization", "Bearer "+loginKey) - revokeResponse, err := session.Client.Do(revokeRequest) + revokeResponse, err := invocation.Client.Do(revokeRequest) if err != nil { return errors.New("you are still logged in, the server could not be reached") @@ -351,7 +351,7 @@ func Logout(session api.Session, arguments []string) error { return fmt.Errorf("you are still logged in: %s", api.ServerError(revokeResponse)) } - fmt.Fprintln(session.Out, "Logged out.") + fmt.Fprintln(invocation.Out, "Logged out.") err = os.Remove(path) diff --git a/internal/login/login_test.go b/internal/login/login_test.go index 341c47a..ce45dbd 100644 --- a/internal/login/login_test.go +++ b/internal/login/login_test.go @@ -88,7 +88,7 @@ func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, dev } if len(*polledAt) >= len(pollAnswers) { - t.Error("polled more often than the script allows") + t.Error("the provider was polled too many times") http.Error(w, "over-polled", http.StatusTooManyRequests) return } @@ -112,7 +112,7 @@ func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, dev return polledAt, server.URL } -func fakeSuperstack(t *testing.T, providersRefusal string, loginAnswer string) (api.Session, *bytes.Buffer) { +func fakeSuperstack(t *testing.T, providersRefusal string, loginAnswer string) (api.Invocation, *bytes.Buffer) { t.Helper() mux := http.NewServeMux() @@ -158,10 +158,10 @@ func fakeSuperstack(t *testing.T, providersRefusal string, loginAnswer string) ( t.Cleanup(server.Close) out := &bytes.Buffer{} - session := api.NewSession(server.URL, "test", strings.NewReader(""), out) - session.OpenBrowser = func(url string) {} + invocation := api.NewInvocation(server.URL, "test", strings.NewReader(""), out) + invocation.OpenBrowser = func(url string) {} - return session, out + return invocation, out } func TestLogin(t *testing.T) { @@ -195,7 +195,7 @@ func TestLogin(t *testing.T) { wantError: "the code expired before it was entered, run login again", }, { - name: "poll has neither an error nor a token", + name: "poll has neither an error nor a provider access token", provider: "github", pollAnswers: []string{`{}`}, wantError: "the login did not complete on github, run login again", @@ -207,7 +207,7 @@ func TestLogin(t *testing.T) { wantError: "the login did not complete on github, run login again", }, { - name: "superstack returns an empty key", + name: "superstack returns an empty login key", provider: "github", pollAnswers: []string{`{"access_token":"gho_test"}`}, loginAnswer: `{"key":"","email":"someone@example.com"}`, @@ -287,7 +287,7 @@ func TestLogin(t *testing.T) { wantError: "expired", }, { - name: "server rejects the token", + name: "server rejects the provider access token", provider: "github", pollAnswers: []string{`{"access_token": "gho_stolen"}`}, wantError: "did not confirm the login", @@ -296,7 +296,7 @@ func TestLogin(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) deviceInterval := test.deviceInterval @@ -305,15 +305,15 @@ func TestLogin(t *testing.T) { } polledAt, providerBase := fakeProviderForLogin(t, test.provider, deviceInterval, test.deviceAnswer, test.pollAnswers) - session, _ := fakeSuperstack(t, test.providersError, test.loginAnswer) + invocation, _ := fakeSuperstack(t, test.providersError, test.loginAnswer) if test.provider == "gitlab" { - session.GitlabBase = providerBase + invocation.GitlabBase = providerBase } else { - session.GithubBase = providerBase + invocation.GithubBase = providerBase } - err := Login(session, []string{test.provider}) + err := Login(invocation, []string{test.provider}) if test.wantPollGap > 0 { if len(*polledAt) < 2 { @@ -332,10 +332,10 @@ func TestLogin(t *testing.T) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) } - path, _ := api.KeyPath() + path, _ := api.LoginKeyPath() if _, statError := os.Stat(path); statError == nil { - t.Fatal("a key was stored despite the failed login") + t.Fatal("a login key was stored despite the failed login") } return @@ -345,7 +345,7 @@ func TestLogin(t *testing.T) { t.Fatal(err) } - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) @@ -358,7 +358,7 @@ func TestLogin(t *testing.T) { } if strings.TrimSpace(string(stored)) != "ssk_test" { - t.Errorf("stored key = %q, want ssk_test", stored) + t.Errorf("stored login key = %q, want ssk_test", stored) } info, err := os.Stat(path) @@ -368,23 +368,23 @@ func TestLogin(t *testing.T) { } if info.Mode().Perm() != 0o600 { - t.Errorf("key file mode = %v, want 0600", info.Mode().Perm()) + t.Errorf("login key file mode = %v, want 0600", info.Mode().Perm()) } }) } } func TestLoginOpensTheBrowserOnEnter(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) _, providerBase := fakeProviderForLogin(t, "gitlab", 1, "", []string{`{"access_token": "glpat-test"}`}) - session, _ := fakeSuperstack(t, "", "") - session.GitlabBase = providerBase - session.In = strings.NewReader("\n") + invocation, _ := fakeSuperstack(t, "", "") + invocation.GitlabBase = providerBase + invocation.In = strings.NewReader("\n") browserOpens := make(chan string, 1) - session.OpenBrowser = func(url string) { browserOpens <- url } + invocation.OpenBrowser = func(url string) { browserOpens <- url } - err := Login(session, []string{"gitlab"}) + err := Login(invocation, []string{"gitlab"}) if err != nil { t.Fatal(err) @@ -393,7 +393,7 @@ func TestLoginOpensTheBrowserOnEnter(t *testing.T) { 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) + t.Errorf("the browser opened %q, want the verification page with the code filled in", url) } case <-time.After(2 * time.Second): @@ -413,7 +413,7 @@ func TestLoginRequiresAProvider(t *testing.T) { } for _, test := range tests { - err := Login(api.Session{}, test.arguments) + err := Login(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), "a provider: github or gitlab") { t.Errorf("%s: error = %v, want the provider hint", test.name, err) @@ -422,7 +422,7 @@ func TestLoginRequiresAProvider(t *testing.T) { } func TestLoginProviderNotOffered(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) mux := http.NewServeMux() @@ -435,9 +435,9 @@ func TestLoginProviderNotOffered(t *testing.T) { t.Cleanup(server.Close) out := &bytes.Buffer{} - session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + invocation := api.NewInvocation(server.URL, "test", strings.NewReader(""), out) - err := Login(session, []string{"gitlab"}) + err := Login(invocation, []string{"gitlab"}) if err == nil || !strings.Contains(err.Error(), "offers no gitlab login") { t.Fatalf("error = %v, want it to say the server offers no gitlab login", err) @@ -446,16 +446,16 @@ func TestLoginProviderNotOffered(t *testing.T) { func TestLogout(t *testing.T) { tests := []struct { - name string - arguments []string - storedKey string - storeEmptyKey bool - serverDown bool - revokeStatus int - wantError string - wantRevocation bool - wantKeyKept bool - wantShown string + name string + arguments []string + storedLoginKey string + storeEmptyLoginKey bool + serverDown bool + revokeStatus int + wantError string + wantRevocation bool + wantKeyKept bool + wantShown string }{ { name: "arguments are refused", @@ -463,8 +463,8 @@ func TestLogout(t *testing.T) { wantError: "logout takes no arguments", }, { - name: "revokes and forgets the stored key", - storedKey: "ssk_test", + name: "revokes and forgets the stored login key", + storedLoginKey: "ssk_test", revokeStatus: http.StatusNoContent, wantRevocation: true, wantShown: "Logged out.\n", @@ -474,38 +474,38 @@ func TestLogout(t *testing.T) { wantShown: "Not logged in.\n", }, { - name: "an empty stored login", - storeEmptyKey: true, - wantShown: "Not logged in.\n", - wantKeyKept: true, + name: "an empty stored login", + storeEmptyLoginKey: true, + wantShown: "Not logged in.\n", + wantKeyKept: true, }, { name: "server refuses the revocation", - storedKey: "ssk_test", + storedLoginKey: "ssk_test", revokeStatus: http.StatusServiceUnavailable, wantError: "still logged in", wantRevocation: true, wantKeyKept: true, }, { - name: "server unreachable", - storedKey: "ssk_test", - serverDown: true, - wantError: "still logged in", - wantKeyKept: true, + name: "server unreachable", + storedLoginKey: "ssk_test", + serverDown: true, + wantError: "still logged in", + wantKeyKept: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) - revokedKey := "" + revokedLoginKey := "" mux := http.NewServeMux() mux.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) { - revokedKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + revokedLoginKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") w.WriteHeader(test.revokeStatus) }) @@ -519,29 +519,29 @@ func TestLogout(t *testing.T) { } out := &bytes.Buffer{} - session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + invocation := api.NewInvocation(server.URL, "test", strings.NewReader(""), out) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) } - if test.storedKey != "" || test.storeEmptyKey { + if test.storedLoginKey != "" || test.storeEmptyLoginKey { err = os.MkdirAll(filepath.Dir(path), 0o700) if err != nil { t.Fatal(err) } - err = os.WriteFile(path, []byte(test.storedKey+"\n"), 0o600) + err = os.WriteFile(path, []byte(test.storedLoginKey+"\n"), 0o600) if err != nil { t.Fatal(err) } } - err = Logout(session, test.arguments) + err = Logout(invocation, test.arguments) if test.wantError != "" { if err == nil || !strings.Contains(err.Error(), test.wantError) { @@ -551,22 +551,22 @@ func TestLogout(t *testing.T) { t.Fatal(err) } - if test.wantRevocation && revokedKey != test.storedKey { - t.Errorf("the server saw %q revoked, want %q", revokedKey, test.storedKey) + if test.wantRevocation && revokedLoginKey != test.storedLoginKey { + t.Errorf("the server saw %q revoked, want %q", revokedLoginKey, test.storedLoginKey) } - if !test.wantRevocation && revokedKey != "" { - t.Errorf("the server saw a revocation for %q, want none", revokedKey) + if !test.wantRevocation && revokedLoginKey != "" { + t.Errorf("the server saw a revocation for %q, want none", revokedLoginKey) } _, statError := os.Stat(path) if test.wantKeyKept && statError != nil { - t.Error("the stored key is gone although the revocation failed") + t.Error("the stored login key is gone although the revocation failed") } if !test.wantKeyKept && !os.IsNotExist(statError) { - t.Error("the stored key still exists after logout") + t.Error("the stored login key still exists after logout") } if out.String() != test.wantShown { @@ -577,9 +577,9 @@ func TestLogout(t *testing.T) { } func TestLoginReplacesAStoredLoginLeftTooOpen(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) @@ -604,10 +604,10 @@ func TestLoginReplacesAStoredLoginLeftTooOpen(t *testing.T) { } _, providerBase := fakeProviderForLogin(t, "gitlab", 1, "", []string{`{"access_token": "glpat-test"}`}) - session, _ := fakeSuperstack(t, "", "") - session.GitlabBase = providerBase + invocation, _ := fakeSuperstack(t, "", "") + invocation.GitlabBase = providerBase - err = Login(session, []string{"gitlab"}) + err = Login(invocation, []string{"gitlab"}) if err != nil { t.Fatal(err) @@ -645,13 +645,13 @@ func TestLoginReplacesAStoredLoginLeftTooOpen(t *testing.T) { } func TestLoginShowsTheCodeAndWhereToEnterIt(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) _, providerBase := fakeProviderForLogin(t, "gitlab", 1, "", []string{`{"access_token": "glpat-test"}`}) - session, out := fakeSuperstack(t, "", "") - session.GitlabBase = providerBase + invocation, out := fakeSuperstack(t, "", "") + invocation.GitlabBase = providerBase - err := Login(session, []string{"gitlab"}) + err := Login(invocation, []string{"gitlab"}) if err != nil { t.Fatal(err) @@ -672,9 +672,9 @@ func TestLoginKeepsAWorkingLoginWhenTheNewOneCannotBeSaved(t *testing.T) { t.Skip("root ignores the folder mode this test rests on") } - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) @@ -703,10 +703,10 @@ func TestLoginKeepsAWorkingLoginWhenTheNewOneCannotBeSaved(t *testing.T) { t.Cleanup(func() { os.Chmod(directory, 0o700) }) _, providerBase := fakeProviderForLogin(t, "gitlab", 1, "", []string{`{"access_token": "glpat-test"}`}) - session, out := fakeSuperstack(t, "", "") - session.GitlabBase = providerBase + invocation, out := fakeSuperstack(t, "", "") + invocation.GitlabBase = providerBase - err = Login(session, []string{"gitlab"}) + err = Login(invocation, []string{"gitlab"}) if err == nil || !strings.Contains(err.Error(), "could not be saved") { t.Fatalf("error = %v, want it to say the login could not be saved", err) @@ -723,14 +723,14 @@ func TestLoginKeepsAWorkingLoginWhenTheNewOneCannotBeSaved(t *testing.T) { } if strings.Contains(out.String(), "Logged in as") { - t.Errorf("output = %q, want no claim that the login succeeded", out.String()) + t.Errorf("output = %q, want no statement that the login succeeded", out.String()) } } func TestLogoutToleratesALoginAlreadyRemoved(t *testing.T) { - apitest.IsolateKeyStorage(t) + apitest.IsolateLoginKeyStorage(t) - path, err := api.KeyPath() + path, err := api.LoginKeyPath() if err != nil { t.Fatal(err) @@ -762,9 +762,9 @@ func TestLogoutToleratesALoginAlreadyRemoved(t *testing.T) { defer server.Close() out := &bytes.Buffer{} - session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + invocation := api.NewInvocation(server.URL, "test", strings.NewReader(""), out) - err = Logout(session, nil) + err = Logout(invocation, nil) if err != nil { t.Fatalf("error = %v, want a login already removed to be no failure", err) diff --git a/internal/member/member.go b/internal/member/member.go index 4c85dff..7061116 100644 --- a/internal/member/member.go +++ b/internal/member/member.go @@ -14,7 +14,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api" ) -func Add(session api.Session, arguments []string) error { +func Add(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 || arguments[0] == "" { return errors.New("member add takes an email address and a fleet id") } @@ -33,7 +33,7 @@ func Add(session api.Session, arguments []string) error { return err } - request, err := api.AuthenticatedRequest(session, http.MethodPost, + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", bytes.NewReader(body)) if err != nil { @@ -42,10 +42,10 @@ func Add(session api.Session, arguments []string) error { request.Header.Set("Content-Type", "application/json") - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -54,12 +54,12 @@ func Add(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Gave %s access to fleet %d.\n", email, fleetId) + fmt.Fprintf(invocation.Out, "Added member %s to fleet %d.\n", email, fleetId) return nil } -func List(session api.Session, arguments []string) error { +func List(invocation api.Invocation, arguments []string) error { positionals, jsonOutput := api.TakeJsonFlag(arguments) if len(positionals) != 1 { @@ -72,17 +72,17 @@ func List(session api.Session, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - request, err := api.AuthenticatedRequest(session, http.MethodGet, + request, err := api.AuthenticatedRequest(invocation, http.MethodGet, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -103,7 +103,7 @@ func List(session api.Session, arguments []string) error { } if jsonOutput { - err = json.NewEncoder(session.Out).Encode(people) + err = json.NewEncoder(invocation.Out).Encode(people) return err } @@ -117,18 +117,18 @@ func List(session api.Session, arguments []string) error { emailWidth = max(emailWidth, len(members[index])) } - fmt.Fprintf(session.Out, "%-*s %s\n", emailWidth, "EMAIL", "ROLE") + fmt.Fprintf(invocation.Out, "%-*s %s\n", emailWidth, "EMAIL", "ROLE") - fmt.Fprintf(session.Out, "%-*s owner\n", emailWidth, owner) + fmt.Fprintf(invocation.Out, "%-*s owner\n", emailWidth, owner) for _, email := range members { - fmt.Fprintf(session.Out, "%-*s member\n", emailWidth, email) + fmt.Fprintf(invocation.Out, "%-*s member\n", emailWidth, email) } return nil } -func Remove(session api.Session, arguments []string) error { +func Remove(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 || arguments[0] == "" { return errors.New("member remove takes an email address and a fleet id") } @@ -141,7 +141,7 @@ func Remove(session api.Session, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - fleets, err := api.FetchFleets(session) + fleets, err := api.FetchFleets(invocation) if err != nil { return err @@ -161,28 +161,28 @@ func Remove(session api.Session, arguments []string) error { return errors.New("no such fleet") } - fmt.Fprintf(session.Out, "Take away %s's access to fleet %q? [y/N] ", email, name) + fmt.Fprintf(invocation.Out, "Remove member %s from fleet %q? [y/N] ", email, name) - answer, _ := bufio.NewReader(session.In).ReadString('\n') + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') answer = strings.ToLower(strings.TrimSpace(answer)) if answer != "y" && answer != "yes" { - fmt.Fprintln(session.Out, "Nothing removed.") + fmt.Fprintln(invocation.Out, "Nothing removed.") return nil } - request, err := api.AuthenticatedRequest(session, http.MethodDelete, + request, err := api.AuthenticatedRequest(invocation, http.MethodDelete, "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members/"+url.PathEscape(email), nil) if err != nil { return err } - response, err := session.Client.Do(request) + response, err := invocation.Client.Do(request) if err != nil { - return errors.New("the server could not be reached, check your connection") + return errors.New("the server could not be reached, check your internet access") } defer response.Body.Close() @@ -191,7 +191,7 @@ func Remove(session api.Session, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(session.Out, "Removed %s's access to fleet %q.\n", email, name) + fmt.Fprintf(invocation.Out, "Removed member %s from fleet %q.\n", email, name) return nil } diff --git a/internal/member/member_test.go b/internal/member/member_test.go index 442b76c..630a884 100644 --- a/internal/member/member_test.go +++ b/internal/member/member_test.go @@ -18,7 +18,7 @@ func TestMemberAdd(t *testing.T) { wantOutput string wantError string }{ - {name: "added", wantOutput: "Gave member@example.com access to fleet 3.\n"}, + {name: "added", wantOutput: "Added member member@example.com to fleet 3.\n"}, {name: "server refusal", refusal: "no such account", wantError: "no such account"}, } @@ -45,9 +45,9 @@ func TestMemberAdd(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := Add(session, []string{"member@example.com", "3"}) + err := Add(invocation, []string{"member@example.com", "3"}) if test.wantError != "" { if err == nil || err.Error() != test.wantError { @@ -82,7 +82,7 @@ func TestMemberAddArguments(t *testing.T) { } for _, test := range tests { - err := Add(api.Session{}, test.arguments) + err := Add(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) @@ -177,9 +177,9 @@ func TestMemberList(t *testing.T) { fmt.Fprint(w, test.people) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - err := List(session, test.arguments) + err := List(invocation, test.arguments) printed := out.String() @@ -225,8 +225,8 @@ func TestMemberRemove(t *testing.T) { }{ {name: "a plain address", email: "member@example.com", answer: "y\n", wantRemoved: true}, {name: "an address with a hash", email: "a#b@example.com", answer: "yes\n", wantRemoved: true}, - {name: "the question names the fleet", email: "member@example.com", answer: "n\n", wantShown: `Take away member@example.com's access to fleet "pilot"?`}, - {name: "the success line names the fleet", email: "member@example.com", answer: "y\n", wantRemoved: true, wantShown: `Removed member@example.com's access to fleet "pilot".`}, + {name: "the question names the fleet", email: "member@example.com", answer: "n\n", wantShown: `Remove member member@example.com from fleet "pilot"?`}, + {name: "the success line names the fleet", email: "member@example.com", answer: "y\n", wantRemoved: true, wantShown: `Removed member member@example.com from fleet "pilot".`}, {name: "declined by default", email: "member@example.com", answer: "\n", wantShown: "Nothing removed"}, {name: "declined with n", email: "member@example.com", answer: "n\n", wantShown: "Nothing removed"}, {name: "closed input", email: "member@example.com", wantShown: "Nothing removed"}, @@ -263,11 +263,11 @@ func TestMemberRemove(t *testing.T) { w.WriteHeader(http.StatusNoContent) }) - session, out := apitest.LoggedInSession(t, mux) + invocation, out := apitest.LoggedInInvocation(t, mux) - session.In = strings.NewReader(test.answer) + invocation.In = strings.NewReader(test.answer) - err := Remove(session, []string{test.email, "3"}) + err := Remove(invocation, []string{test.email, "3"}) printed := out.String() @@ -308,7 +308,7 @@ func TestMemberRemoveArguments(t *testing.T) { } for _, test := range tests { - err := Remove(api.Session{}, test.arguments) + err := Remove(api.Invocation{}, test.arguments) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) diff --git a/main.go b/main.go index 372987d..b3ebbc5 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/device" "github.com/siliconwitchery/superstack-cli/internal/dispatch" "github.com/siliconwitchery/superstack-cli/internal/fleet" - "github.com/siliconwitchery/superstack-cli/internal/key" + "github.com/siliconwitchery/superstack-cli/internal/fleetkey" "github.com/siliconwitchery/superstack-cli/internal/login" "github.com/siliconwitchery/superstack-cli/internal/member" ) @@ -30,16 +30,16 @@ var sections = []dispatch.Section{ {Name: "fleet list", Arguments: "[--json]", Summary: "List the fleets you can reach", Run: fleet.List}, {Name: "fleet rename", Arguments: " ", Summary: "Rename a fleet", Run: fleet.Rename}, {Name: "fleet transfer", Arguments: " ", Summary: "Hand a fleet to a new owner", Run: fleet.Transfer}, - {Name: "fleet delete", Arguments: "", Summary: "Delete a fleet and release its devices", Run: fleet.Delete}, + {Name: "fleet delete", Arguments: "", Summary: "Delete a fleet and unpair its devices", Run: fleet.Delete}, }, }, { Title: "Devices", Commands: []dispatch.Command{ - {Name: "device claim", Arguments: " [name]", Summary: "Claim a device into a fleet, then press its pairing button", Run: device.Claim}, - {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices, their state, and when they were last seen", Run: device.List}, + {Name: "device pair", Arguments: " [name]", Summary: "Pair a device with a fleet using its pairing button", Run: device.Pair}, + {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices, their run state, and when they were last seen", Run: device.List}, {Name: "device rename", Arguments: " ", Summary: "Rename a device", Run: device.Rename}, - {Name: "device release", Arguments: "", Summary: "Release a device from its fleet, wiping its files and restarting its code", Run: device.Release}, + {Name: "device unpair", Arguments: "", Summary: "Unpair a device, wipe its user files, and restart Lua", Run: device.Unpair}, {Name: "device start", Arguments: "", Summary: "Start the code on a device"}, {Name: "device stop", Arguments: "", Summary: "Stop the code on a device"}, {Name: "device restart", Arguments: "", Summary: "Restart the code on a device"}, @@ -62,24 +62,24 @@ var sections = []dispatch.Section{ { Title: "People", Commands: []dispatch.Command{ - {Name: "member add", Arguments: " ", Summary: "Give someone access to a fleet", Run: member.Add}, - {Name: "member list", Arguments: " [--json]", Summary: "List the people who can reach a fleet", Run: member.List}, - {Name: "member remove", Arguments: " ", Summary: "Take away someone's access", Run: member.Remove}, + {Name: "member add", Arguments: " ", Summary: "Add a member to a fleet", Run: member.Add}, + {Name: "member list", Arguments: " [--json]", Summary: "List a fleet's owner and members", Run: member.List}, + {Name: "member remove", Arguments: " ", Summary: "Remove a member from a fleet", Run: member.Remove}, }, }, { Title: "Fleet keys", Commands: []dispatch.Command{ - {Name: "key create", Arguments: "