diff --git a/CLAUDE.md b/CLAUDE.md index 62ce910..be58ec4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,8 +106,7 @@ server base URL, the stored key location, the request builder that stamps the CLI version into User-Agent for the server's version negotiation, the reachability check `main` runs before dispatching any command that talks to the server, and the hidden `--server ` flag development uses to aim a -run at another server. The flag is deliberately absent from the help and wins -over `SUPERSTACK_API`. +run at another server. The flag is deliberately absent from the help. Targets are positional. A fleet is named by the id `fleet list` shows, a device by its IMEI, and a verb that can act on either takes one argument diff --git a/README.md b/README.md index 3a1b60e..9cb654a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Superstack CLI `superstack` is the command line interface to Superstack: sign in, claim -devices, push Lua code, and stream events and logs from your fleet. It is a +devices, push Lua code, and stream logs from your fleet. It is a single static binary talking to the Superstack server's JSON API. The server is a separate project; this repo is the CLI only. It is laid out as follows: diff --git a/internal/commands/client.go b/internal/commands/client.go index c92bc96..1a36e3c 100644 --- a/internal/commands/client.go +++ b/internal/commands/client.go @@ -77,10 +77,6 @@ func CheckServer() error { func apiRequest(method string, path string, body io.Reader) (*http.Request, error) { base := chosenApiBase - if base == "" { - base = os.Getenv("SUPERSTACK_API") - } - if base == "" { base = defaultApiBase } diff --git a/internal/commands/client_test.go b/internal/commands/client_test.go index d75463b..d9744fc 100644 --- a/internal/commands/client_test.go +++ b/internal/commands/client_test.go @@ -1,6 +1,7 @@ package commands import ( + "io" "net/http" "net/http/httptest" "os" @@ -10,6 +11,34 @@ import ( "testing" ) +func captureStdout(t *testing.T, run func() error) (string, error) { + t.Helper() + + readEnd, writeEnd, err := os.Pipe() + + if err != nil { + t.Fatal(err) + } + + stdout := os.Stdout + + os.Stdout = writeEnd + + runError := run() + + os.Stdout = stdout + + writeEnd.Close() + + printed, err := io.ReadAll(readEnd) + + if err != nil { + t.Fatal(err) + } + + return string(printed), runError +} + func isolateKeyStorage(t *testing.T) string { t.Helper() @@ -49,7 +78,9 @@ func loggedInTestServer(t *testing.T, handler http.Handler) { t.Cleanup(server.Close) - t.Setenv("SUPERSTACK_API", server.URL) + chosenApiBase = server.URL + + t.Cleanup(func() { chosenApiBase = "" }) } func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { @@ -187,11 +218,10 @@ func TestTakeServerFlag(t *testing.T) { } } -func TestApiRequestBasePrecedence(t *testing.T) { +func TestApiRequestBase(t *testing.T) { tests := []struct { name string chosenBase string - envBase string wantUrl string }{ { @@ -199,14 +229,8 @@ func TestApiRequestBasePrecedence(t *testing.T) { wantUrl: defaultApiBase + "/login", }, { - name: "the environment overrides the default", - envBase: "http://localhost:7777", - wantUrl: "http://localhost:7777/login", - }, - { - name: "the flag overrides the environment", + name: "the flag overrides the default", chosenBase: "http://localhost:8888", - envBase: "http://localhost:7777", wantUrl: "http://localhost:8888/login", }, } @@ -217,8 +241,6 @@ func TestApiRequestBasePrecedence(t *testing.T) { t.Cleanup(func() { chosenApiBase = "" }) - t.Setenv("SUPERSTACK_API", test.envBase) - request, err := apiRequest(http.MethodGet, "/login", nil) if err != nil { @@ -241,7 +263,9 @@ func TestCheckServer(t *testing.T) { defer reachable.Close() - t.Setenv("SUPERSTACK_API", reachable.URL) + chosenApiBase = reachable.URL + + t.Cleanup(func() { chosenApiBase = "" }) err := CheckServer() @@ -253,7 +277,7 @@ func TestCheckServer(t *testing.T) { unreachable.Close() - t.Setenv("SUPERSTACK_API", unreachable.URL) + chosenApiBase = unreachable.URL err = CheckServer() diff --git a/internal/commands/fleet_list.go b/internal/commands/fleet_list.go index cd32562..ee12986 100644 --- a/internal/commands/fleet_list.go +++ b/internal/commands/fleet_list.go @@ -34,14 +34,16 @@ func FleetList(arguments []string) error { return nil } - idWidth := 0 - nameWidth := 0 + idWidth := len("ID") + nameWidth := len("NAME") for _, fleet := range fleets { idWidth = max(idWidth, len(strconv.FormatInt(fleet.Id, 10))) nameWidth = max(nameWidth, len(fleet.Name)) } + fmt.Printf("%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "ROLE") + for _, fleet := range fleets { role := "member" diff --git a/internal/commands/key_create.go b/internal/commands/key_create.go new file mode 100644 index 0000000..7131c7b --- /dev/null +++ b/internal/commands/key_create.go @@ -0,0 +1,69 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func KeyCreate(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 := authenticatedRequest(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 := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + created := struct { + Id int64 `json:"id"` + Key string `json:"key"` + }{} + + err = json.NewDecoder(response.Body).Decode(&created) + + if err != nil { + return err + } + + fmt.Printf("Created key %d.\n\n %s\n\nAnyone holding it can send data to the fleet, and it is shown only this once.\n", created.Id, created.Key) + + return nil +} diff --git a/internal/commands/key_create_test.go b/internal/commands/key_create_test.go new file mode 100644 index 0000000..2dd82ac --- /dev/null +++ b/internal/commands/key_create_test.go @@ -0,0 +1,114 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +func TestKeyCreate(t *testing.T) { + tests := []struct { + name string + arguments []string + wantPath string + wantLabel string + refusal string + wantError string + }{ + { + name: "a labelled key", + arguments: []string{"3", "deploy server"}, + wantPath: "/fleets/3/keys", + wantLabel: "deploy server", + }, + { + name: "no label", + arguments: []string{"3"}, + wantError: "takes a fleet id and a label", + }, + { + name: "an empty label", + arguments: []string{"3", ""}, + wantError: "takes a fleet id and a label", + }, + { + name: "no fleet id", + arguments: []string{}, + wantError: "takes a fleet id and a label", + }, + { + name: "too many words", + arguments: []string{"3", "deploy", "server"}, + wantError: "takes a fleet id and a label", + }, + { + name: "a wordy id", + arguments: []string{"pilot", "deploy server"}, + wantError: "shown by fleet list", + }, + { + name: "the server refuses", + arguments: []string{"9", "doomed"}, + wantPath: "/fleets/9/keys", + refusal: "no such fleet", + wantError: "the server said: no such fleet", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/keys", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.wantPath { + t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) + } + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + sent := struct { + Label string `json:"label"` + }{} + + err := json.NewDecoder(r.Body).Decode(&sent) + + if err != nil { + t.Errorf("the request body could not be decoded: %v", err) + } + + if sent.Label != test.wantLabel { + t.Errorf("the request carried label %q, want %q", sent.Label, test.wantLabel) + } + + fmt.Fprint(w, `{"id":1,"key":"ssf_testtesttestab2de"}`) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return KeyCreate(test.arguments) + }) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(printed, "ssf_testtesttestab2de") { + t.Errorf("the output %q does not show the key", printed) + } + }) + } +} diff --git a/internal/commands/key_list.go b/internal/commands/key_list.go new file mode 100644 index 0000000..4cd6cfa --- /dev/null +++ b/internal/commands/key_list.go @@ -0,0 +1,134 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" +) + +type keyEntry struct { + Id int64 `json:"id"` + Fleet int64 `json:"fleet"` + Label string `json:"label"` + Suffix string `json:"suffix"` +} + +func KeyList(arguments []string) error { + + jsonOutput := false + + positionals := []string{} + + for _, argument := range arguments { + if argument == "--json" { + jsonOutput = true + continue + } + + positionals = append(positionals, argument) + } + + if len(positionals) > 1 { + return errors.New("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 := fetchFleets() + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + request, err := authenticatedRequest(http.MethodGet, "/keys", nil) + + if err != nil { + return err + } + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fetched := []keyEntry{} + + err = json.NewDecoder(response.Body).Decode(&fetched) + + if err != nil { + return err + } + + keys := []keyEntry{} + + for _, key := range fetched { + if chosenFleetId == 0 || key.Fleet == chosenFleetId { + keys = append(keys, key) + } + } + + if jsonOutput { + return json.NewEncoder(os.Stdout).Encode(keys) + } + + if len(keys) == 0 { + fmt.Println("No keys yet. Create one with key create.") + return nil + } + + idWidth := len("ID") + fleetIdWidth := len("FLEET") + fleetNameWidth := len("FLEET NAME") + + for _, key := range keys { + idWidth = max(idWidth, len(strconv.FormatInt(key.Id, 10))) + fleetIdWidth = max(fleetIdWidth, len(strconv.FormatInt(key.Fleet, 10))) + fleetNameWidth = max(fleetNameWidth, len(fleetNames[key.Fleet])) + } + + fmt.Printf("%-*s %-*s %-*s %-8s %s\n", + idWidth, "ID", fleetIdWidth, "FLEET", fleetNameWidth, "FLEET NAME", "KEY", "LABEL") + + for _, key := range keys { + fmt.Printf("%-*d %-*d %-*s ...%s %s\n", + idWidth, key.Id, fleetIdWidth, key.Fleet, fleetNameWidth, fleetNames[key.Fleet], key.Suffix, key.Label) + } + + return nil +} diff --git a/internal/commands/key_list_test.go b/internal/commands/key_list_test.go new file mode 100644 index 0000000..709e485 --- /dev/null +++ b/internal/commands/key_list_test.go @@ -0,0 +1,114 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestKeyList(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"},` + + `{"id":2,"fleet":4,"label":"lab sensor","suffix":"f9hjk"}]` + + tests := []struct { + name string + arguments []string + wantShown []string + wantHidden []string + wantError string + }{ + { + name: "every fleet's keys", + arguments: []string{}, + wantShown: []string{"ID FLEET FLEET NAME", "crew", "skunkworks", "...ab2de", "...f9hjk", "deploy server", "lab sensor"}, + }, + { + name: "one fleet's keys", + arguments: []string{"3"}, + wantShown: []string{"ID FLEET FLEET NAME", "crew", "...ab2de"}, + wantHidden: []string{"skunkworks", "f9hjk", "lab sensor"}, + }, + { + name: "a fleet without keys", + arguments: []string{"5"}, + wantShown: []string{"No keys yet"}, + wantHidden: []string{"ID FLEET"}, + }, + { + name: "machine-readable output", + arguments: []string{"--json"}, + wantShown: []string{`"suffix":"ab2de"`, `"fleet":4`}, + wantHidden: []string{"ID FLEET"}, + }, + { + name: "the flag before the id", + arguments: []string{"--json", "3"}, + wantShown: []string{`"id":1`}, + wantHidden: []string{`"id":2`, "ID FLEET"}, + }, + { + name: "a fleet out of reach", + arguments: []string{"9"}, + wantError: "no such fleet", + }, + { + name: "two fleet ids", + arguments: []string{"3", "4"}, + wantError: "takes at most one fleet id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by fleet list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fleets) + }) + + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, keys) + }) + + loggedInTestServer(t, mux) + + printed, err := captureStdout(t, func() error { + return KeyList(test.arguments) + }) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("the output %q leaves out %q", printed, want) + } + } + + for _, hidden := range test.wantHidden { + if strings.Contains(printed, hidden) { + t.Errorf("the output %q shows %q, want it filtered out", printed, hidden) + } + } + }) + } +} diff --git a/internal/commands/key_revoke.go b/internal/commands/key_revoke.go new file mode 100644 index 0000000..0d14fcc --- /dev/null +++ b/internal/commands/key_revoke.go @@ -0,0 +1,48 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func KeyRevoke(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") + } + + request, err := authenticatedRequest(http.MethodDelete, + "/keys/"+strconv.FormatInt(keyId, 10), nil) + + if err != nil { + return err + } + + response, err := apiClient.Do(request) + + if err != nil { + return fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Revoked key %d.\n", keyId) + + return nil +} diff --git a/internal/commands/key_revoke_test.go b/internal/commands/key_revoke_test.go new file mode 100644 index 0000000..0ec5e63 --- /dev/null +++ b/internal/commands/key_revoke_test.go @@ -0,0 +1,80 @@ +package commands + +import ( + "net/http" + "strings" + "testing" +) + +func TestKeyRevoke(t *testing.T) { + tests := []struct { + name string + arguments []string + wantPath string + refusal string + wantError string + }{ + { + name: "revoke a key", + arguments: []string{"3"}, + wantPath: "/keys/3", + }, + { + name: "a key out of reach", + arguments: []string{"9"}, + wantPath: "/keys/9", + refusal: "no such key", + wantError: "the server said: no such key", + }, + { + name: "no key id", + arguments: []string{}, + wantError: "takes a key id", + }, + { + name: "two key ids", + arguments: []string{"3", "4"}, + wantError: "takes a key id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by key list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("DELETE /keys/{id}", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.wantPath { + t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) + } + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := KeyRevoke(test.arguments) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/commands/login_test.go b/internal/commands/login_test.go index ee9986e..2e42b94 100644 --- a/internal/commands/login_test.go +++ b/internal/commands/login_test.go @@ -152,7 +152,9 @@ func fakeSuperstack(t *testing.T) { t.Cleanup(server.Close) - t.Setenv("SUPERSTACK_API", server.URL) + chosenApiBase = server.URL + + t.Cleanup(func() { chosenApiBase = "" }) } func TestLogin(t *testing.T) { @@ -353,7 +355,9 @@ func TestLoginProviderNotOffered(t *testing.T) { t.Cleanup(server.Close) - t.Setenv("SUPERSTACK_API", server.URL) + chosenApiBase = server.URL + + t.Cleanup(func() { chosenApiBase = "" }) err := Login([]string{"gitlab"}) diff --git a/internal/commands/logout_test.go b/internal/commands/logout_test.go index 88feac2..12f6205 100644 --- a/internal/commands/logout_test.go +++ b/internal/commands/logout_test.go @@ -67,7 +67,9 @@ func TestLogout(t *testing.T) { server.Close() } - t.Setenv("SUPERSTACK_API", server.URL) + chosenApiBase = server.URL + + t.Cleanup(func() { chosenApiBase = "" }) path, err := keyPath() diff --git a/internal/commands/member_list.go b/internal/commands/member_list.go index ba30ed1..90d4a30 100644 --- a/internal/commands/member_list.go +++ b/internal/commands/member_list.go @@ -76,12 +76,14 @@ func MemberList(arguments []string) error { return err } - emailWidth := len(people.Owner) + emailWidth := max(len("EMAIL"), len(people.Owner)) for _, email := range people.Members { emailWidth = max(emailWidth, len(email)) } + fmt.Printf("%-*s %s\n", emailWidth, "EMAIL", "ROLE") + fmt.Printf("%-*s owner\n", emailWidth, people.Owner) for _, email := range people.Members { diff --git a/main.go b/main.go index f90e4fd..1070d19 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/commands" ) -const version = "0.0.2" +const version = "0.0.3" type command struct { name string @@ -27,7 +27,7 @@ var sections = []section{ { title: "Getting started", commands: []command{ - {name: "login", arguments: "", summary: "Log in with the selected provider", run: commands.Login}, + {name: "login", arguments: "", summary: "Log in with the selected provider", run: commands.Login}, {name: "logout", summary: "Log out of your account", run: commands.Logout}, }, }, @@ -41,24 +41,16 @@ var sections = []section{ {name: "fleet delete", arguments: "", summary: "Delete a fleet and release its devices", run: commands.FleetDelete}, }, }, - { - title: "People", - commands: []command{ - {name: "member add", arguments: " ", summary: "Give someone access to a fleet", run: commands.MemberAdd}, - {name: "member list", arguments: "", summary: "List the people who can reach a fleet", run: commands.MemberList}, - {name: "member remove", arguments: " ", summary: "Take away someone's access", run: commands.MemberRemove}, - }, - }, { title: "Devices", commands: []command{ - {name: "claim", arguments: " [name]", summary: "Claim a device into a fleet, then press its button"}, - {name: "devices", arguments: "[fleet_id]", summary: "List devices, their state, and when they were last seen"}, - {name: "rename", arguments: " ", summary: "Rename a device"}, - {name: "release", arguments: "", summary: "Wipe a device and hand it back"}, - {name: "start", arguments: "", summary: "Run the code on the target"}, - {name: "stop", arguments: "", summary: "Halt the code on the target"}, - {name: "restart", arguments: "", summary: "Restart the code on the target"}, + {name: "device claim", arguments: " [name]", summary: "Claim a device into a fleet, then press its button"}, + {name: "device list", arguments: "[fleet_id]", summary: "List devices, their state, and when they were last seen"}, + {name: "device rename", arguments: " ", summary: "Rename a device"}, + {name: "device release", arguments: "", summary: "Unpair a device from its fleet and factory reset it"}, + {name: "device start", arguments: "", summary: "Run the code on the target"}, + {name: "device stop", arguments: "", summary: "Halt the code on the target"}, + {name: "device restart", arguments: "", summary: "Restart the code on the target"}, }, }, { @@ -70,18 +62,33 @@ var sections = []section{ }, }, { - title: "Data", + title: "Logs", commands: []command{ - {name: "tail", arguments: " [--log-file ]", summary: "Stream events and logs as they arrive"}, - {name: "send", arguments: " -m ", summary: "Queue a message for the target to collect"}, + {name: "tail", arguments: " [-n num] [--log-file ]", summary: "Stream the target's log as it arrives"}, + }, + }, + { + title: "People", + commands: []command{ + {name: "member add", arguments: " ", summary: "Give someone access to a fleet", run: commands.MemberAdd}, + {name: "member list", arguments: "", summary: "List the people who can reach a fleet", run: commands.MemberList}, + {name: "member remove", arguments: " ", summary: "Take away someone's access", run: commands.MemberRemove}, + }, + }, + { + title: "Keys", + commands: []command{ + {name: "key create", arguments: "