Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
4 changes: 0 additions & 4 deletions internal/commands/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
52 changes: 38 additions & 14 deletions internal/commands/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"io"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -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()

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -187,26 +218,19 @@ 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
}{
{
name: "the default",
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",
},
}
Expand All @@ -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 {
Expand All @@ -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()

Expand All @@ -253,7 +277,7 @@ func TestCheckServer(t *testing.T) {

unreachable.Close()

t.Setenv("SUPERSTACK_API", unreachable.URL)
chosenApiBase = unreachable.URL

err = CheckServer()

Expand Down
6 changes: 4 additions & 2 deletions internal/commands/fleet_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
69 changes: 69 additions & 0 deletions internal/commands/key_create.go
Original file line number Diff line number Diff line change
@@ -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
}
114 changes: 114 additions & 0 deletions internal/commands/key_create_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading