From 23f592137d101199ab2393f3459c967056c5d521 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Mon, 10 Aug 2026 13:22:37 +0200 Subject: [PATCH 1/5] Made Go the only local development requirement and loaded the Nix dev shell via direnv --- .envrc | 1 + README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/README.md b/README.md index acb9e4e..3fafcff 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ 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: ```sh +├── .envrc # Loads the Nix dev shell via direnv ├── .github/dependabot.yml # Weekly action and module update PRs ├── .github/workflows # CI on pull requests, release on v* tags ├── .gitignore @@ -71,23 +72,26 @@ Each option needs a published release. ## Local development -1. Install [Nix](https://nixos.org) with flakes enabled. +1. Install [Go](https://go.dev) 1.25 or newer. -1. Clone the repository and enter the dev shell: +1. Clone the repository: ```sh git clone git@github.com:siliconwitchery/superstack-cli.git ~/projects/superstack-cli cd ~/projects/superstack-cli - nix develop ``` 1. Build and run: ```sh - go build -o superstack . + CGO_ENABLED=0 go build -o superstack . ./superstack ``` +[Nix](https://nixos.org) users need no Go install: `nix develop` enters the +dev shell, and with [direnv](https://direnv.net) hooked into your shell, +`direnv allow` run once in the checkout loads it automatically from then on. + ## Release setup Do everything below once. From 712f81381c7832c284657bef8ad8413fb581a0f2 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Tue, 11 Aug 2026 11:48:30 +0200 Subject: [PATCH 2/5] Added the fleet and member commands with positional targets and a delete confirmation --- CLAUDE.md | 9 +- internal/commands/client.go | 35 +++++++ internal/commands/client_test.go | 31 ++++++ internal/commands/fleet_create.go | 61 ++++++++++++ internal/commands/fleet_create_test.go | 74 ++++++++++++++ internal/commands/fleet_delete.go | 81 +++++++++++++++ internal/commands/fleet_delete_test.go | 121 +++++++++++++++++++++++ internal/commands/fleet_list.go | 56 +++++++++++ internal/commands/fleet_list_test.go | 62 ++++++++++++ internal/commands/fleet_rename.go | 64 ++++++++++++ internal/commands/fleet_rename_test.go | 64 ++++++++++++ internal/commands/fleet_transfer.go | 60 +++++++++++ internal/commands/fleet_transfer_test.go | 62 ++++++++++++ internal/commands/fleets.go | 47 +++++++++ internal/commands/fleets_test.go | 40 ++++++++ internal/commands/member_add.go | 60 +++++++++++ internal/commands/member_add_test.go | 62 ++++++++++++ internal/commands/member_list.go | 92 +++++++++++++++++ internal/commands/member_list_test.go | 79 +++++++++++++++ internal/commands/member_remove.go | 51 ++++++++++ internal/commands/member_remove_test.go | 67 +++++++++++++ main.go | 49 ++++----- 22 files changed, 1296 insertions(+), 31 deletions(-) create mode 100644 internal/commands/fleet_create.go create mode 100644 internal/commands/fleet_create_test.go create mode 100644 internal/commands/fleet_delete.go create mode 100644 internal/commands/fleet_delete_test.go create mode 100644 internal/commands/fleet_list.go create mode 100644 internal/commands/fleet_list_test.go create mode 100644 internal/commands/fleet_rename.go create mode 100644 internal/commands/fleet_rename_test.go create mode 100644 internal/commands/fleet_transfer.go create mode 100644 internal/commands/fleet_transfer_test.go create mode 100644 internal/commands/fleets.go create mode 100644 internal/commands/fleets_test.go create mode 100644 internal/commands/member_add.go create mode 100644 internal/commands/member_add_test.go create mode 100644 internal/commands/member_list.go create mode 100644 internal/commands/member_list_test.go create mode 100644 internal/commands/member_remove.go create mode 100644 internal/commands/member_remove_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 819d01c..4cdec9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,9 +109,12 @@ 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`. -Targets are flags rather than positions. Every command that touches devices -takes `--fleet` or `--device`, so an operation that can apply to one device or -to many stays one verb instead of splitting into a noun-verb pair per level. +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 +accepting both. There is no default target and no bypass flag: a command +missing its target errors, and the destructive verbs ask for interactive +confirmation before acting. `internal/commands/fleets.go` holds the fleet +fetch the fleet-reading commands share. ## Releases diff --git a/internal/commands/client.go b/internal/commands/client.go index 57b83d6..c92bc96 100644 --- a/internal/commands/client.go +++ b/internal/commands/client.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "os" "path/filepath" @@ -95,6 +96,40 @@ func apiRequest(method string, path string, body io.Reader) (*http.Request, erro return request, nil } +func authenticatedRequest(method string, path string, body io.Reader) (*http.Request, error) { + storedKeyPath, err := keyPath() + + if err != nil { + return nil, err + } + + keyBytes, err := os.ReadFile(storedKeyPath) + + if errors.Is(err, fs.ErrNotExist) { + return nil, errors.New("you are not logged in, run login first") + } + + if err != nil { + return nil, err + } + + key := strings.TrimSpace(string(keyBytes)) + + if key == "" { + return nil, errors.New("you are not logged in, run login first") + } + + request, err := apiRequest(method, path, body) + + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+key) + + return request, nil +} + func keyPath() (string, error) { // The key is state, not configuration: linux dotfile repos routinely // publish all of ~/.config, so the key must never live there. The mac diff --git a/internal/commands/client_test.go b/internal/commands/client_test.go index bd9f7bb..d75463b 100644 --- a/internal/commands/client_test.go +++ b/internal/commands/client_test.go @@ -3,6 +3,7 @@ package commands import ( "net/http" "net/http/httptest" + "os" "path/filepath" "runtime" "strings" @@ -21,6 +22,36 @@ func isolateKeyStorage(t *testing.T) string { return temporary } +func loggedInTestServer(t *testing.T, handler http.Handler) { + t.Helper() + + isolateKeyStorage(t) + + path, err := keyPath() + + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Dir(path), 0o700) + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte("ssk_test\n"), 0o600) + + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(handler) + + t.Cleanup(server.Close) + + t.Setenv("SUPERSTACK_API", server.URL) +} + func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { temporary := isolateKeyStorage(t) diff --git a/internal/commands/fleet_create.go b/internal/commands/fleet_create.go new file mode 100644 index 0000000..e8a8f41 --- /dev/null +++ b/internal/commands/fleet_create.go @@ -0,0 +1,61 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func FleetCreate(arguments []string) error { + + if len(arguments) != 1 || arguments[0] == "" { + return errors.New("fleet create takes one name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": arguments[0]}) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPost, "/fleets", 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"` + Name string `json:"name"` + }{} + + err = json.NewDecoder(response.Body).Decode(&created) + + if err != nil { + return err + } + + fmt.Printf("Created fleet %q with id %d.\n", created.Name, created.Id) + + return nil +} diff --git a/internal/commands/fleet_create_test.go b/internal/commands/fleet_create_test.go new file mode 100644 index 0000000..04c9b45 --- /dev/null +++ b/internal/commands/fleet_create_test.go @@ -0,0 +1,74 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" +) + +func TestFleetCreate(t *testing.T) { + created := "" + + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + + created = body.Name + + fmt.Fprintf(w, `{"id": 5, "name": %q}`, body.Name) + }) + + loggedInTestServer(t, mux) + + err := FleetCreate([]string{"field trial"}) + + if err != nil { + t.Fatal(err) + } + + if created != "field trial" { + t.Errorf("the server saw %q created, want %q", created, "field trial") + } +} + +func TestFleetCreateRelaysARefusal(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "the body must carry a name", http.StatusBadRequest) + }) + + loggedInTestServer(t, mux) + + err := FleetCreate([]string{"field trial"}) + + if err == nil || !strings.Contains(err.Error(), "the server said: the body must carry a name") { + t.Fatalf("error = %v, want the relayed refusal", err) + } +} + +func TestFleetCreateTakesOneName(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {"no arguments", nil}, + {"two words", []string{"field", "trial"}}, + {"an empty name", []string{""}}, + } + + for _, test := range tests { + err := FleetCreate(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) + } + } +} diff --git a/internal/commands/fleet_delete.go b/internal/commands/fleet_delete.go new file mode 100644 index 0000000..0931dc3 --- /dev/null +++ b/internal/commands/fleet_delete.go @@ -0,0 +1,81 @@ +package commands + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" +) + +func FleetDelete(arguments []string) error { + + if len(arguments) != 1 { + return errors.New("fleet delete takes a fleet id") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + name := "" + found := false + + for _, fleet := range fleets { + if fleet.Id == fleetId { + name = fleet.Name + found = true + } + } + + if !found { + return errors.New("no such fleet") + } + + fmt.Printf("Delete %q and release its devices? [y/N] ", name) + + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Println("Nothing deleted.") + return nil + } + + request, err := authenticatedRequest(http.MethodDelete, + "/fleets/"+strconv.FormatInt(fleetId, 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("Deleted %q.\n", name) + + return nil +} diff --git a/internal/commands/fleet_delete_test.go b/internal/commands/fleet_delete_test.go new file mode 100644 index 0000000..cce0a63 --- /dev/null +++ b/internal/commands/fleet_delete_test.go @@ -0,0 +1,121 @@ +package commands + +import ( + "fmt" + "net/http" + "os" + "strings" + "testing" +) + +func answerOnStdin(t *testing.T, answer string) { + t.Helper() + + readEnd, writeEnd, err := os.Pipe() + + if err != nil { + t.Fatal(err) + } + + originalStdin := os.Stdin + + os.Stdin = readEnd + + t.Cleanup(func() { os.Stdin = originalStdin }) + + if answer != "" { + _, err = writeEnd.WriteString(answer) + + if err != nil { + t.Fatal(err) + } + } + + writeEnd.Close() +} + +func TestFleetDelete(t *testing.T) { + tests := []struct { + name string + answer string + wantDeleted bool + }{ + {"confirmed with y", "y\n", true}, + {"confirmed with yes", "YES\n", true}, + {"declined with n", "n\n", false}, + {"declined by default", "\n", false}, + {"closed input", "", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deletedPath := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + deletedPath = r.URL.Path + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + answerOnStdin(t, test.answer) + + err := FleetDelete([]string{"3"}) + + if err != nil { + t.Fatal(err) + } + + if test.wantDeleted && deletedPath != "/fleets/3" { + t.Errorf("the server saw %q deleted, want %q", deletedPath, "/fleets/3") + } + + if !test.wantDeleted && deletedPath != "" { + t.Errorf("the server saw %q deleted although the confirmation was declined", deletedPath) + } + }) + } +} + +func TestFleetDeleteUnknownFleet(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + + loggedInTestServer(t, mux) + + err := FleetDelete([]string{"9"}) + + if err == nil || !strings.Contains(err.Error(), "no such fleet") { + t.Fatalf("error = %v, want no such fleet", err) + } +} + +func TestFleetDeleteArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id"}, + {"two arguments", []string{"3", "4"}, "takes a fleet id"}, + {"a wordy id", []string{"pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := FleetDelete(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/commands/fleet_list.go b/internal/commands/fleet_list.go new file mode 100644 index 0000000..cd32562 --- /dev/null +++ b/internal/commands/fleet_list.go @@ -0,0 +1,56 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strconv" +) + +func FleetList(arguments []string) error { + + jsonOutput := false + + for _, argument := range arguments { + if argument != "--json" { + return fmt.Errorf("fleet list takes no arguments, only --json") + } + + jsonOutput = true + } + + fleets, err := fetchFleets() + + if err != nil { + return err + } + + if jsonOutput { + return json.NewEncoder(os.Stdout).Encode(fleets) + } + + if len(fleets) == 0 { + fmt.Println("No fleets yet. Create one with fleet create.") + return nil + } + + idWidth := 0 + nameWidth := 0 + + for _, fleet := range fleets { + idWidth = max(idWidth, len(strconv.FormatInt(fleet.Id, 10))) + nameWidth = max(nameWidth, len(fleet.Name)) + } + + for _, fleet := range fleets { + role := "member" + + if fleet.Owner { + role = "owner" + } + + fmt.Printf("%-*d %-*s %s\n", idWidth, fleet.Id, nameWidth, fleet.Name, role) + } + + return nil +} diff --git a/internal/commands/fleet_list_test.go b/internal/commands/fleet_list_test.go new file mode 100644 index 0000000..95105de --- /dev/null +++ b/internal/commands/fleet_list_test.go @@ -0,0 +1,62 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestFleetList(t *testing.T) { + tests := []struct { + name string + arguments []string + fleets string + wantError string + }{ + { + name: "some fleets", + fleets: `[{"id":1,"name":"field trial","owner":true},{"id":2,"name":"rooftop","owner":false}]`, + }, + { + name: "no fleets", + fleets: `[]`, + }, + { + name: "machine-readable output", + arguments: []string{"--json"}, + fleets: `[{"id":1,"name":"field trial","owner":true}]`, + }, + { + name: "an unknown argument", + arguments: []string{"--verbose"}, + wantError: "only --json", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.fleets) + }) + + loggedInTestServer(t, mux) + + err := FleetList(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/fleet_rename.go b/internal/commands/fleet_rename.go new file mode 100644 index 0000000..65357f2 --- /dev/null +++ b/internal/commands/fleet_rename.go @@ -0,0 +1,64 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func FleetRename(arguments []string) error { + + if len(arguments) != 2 { + return errors.New("fleet rename takes a fleet id and a name, 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") + } + + name := strings.TrimSpace(arguments[1]) + + if name == "" { + return errors.New("fleet rename takes a fleet id and a name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": name}) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPatch, + "/fleets/"+strconv.FormatInt(fleetId, 10), 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.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Renamed the fleet to %q.\n", name) + + return nil +} diff --git a/internal/commands/fleet_rename_test.go b/internal/commands/fleet_rename_test.go new file mode 100644 index 0000000..6399e9e --- /dev/null +++ b/internal/commands/fleet_rename_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestFleetRename(t *testing.T) { + renamedPath := "" + renamedTo := "" + + mux := http.NewServeMux() + + mux.HandleFunc("PATCH /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + + renamedPath = r.URL.Path + renamedTo = body.Name + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := FleetRename([]string{"9", " pilot "}) + + if err != nil { + t.Fatal(err) + } + + if renamedPath != "/fleets/9" || renamedTo != "pilot" { + t.Errorf("the server saw %q renamed to %q, want %q renamed to %q", + renamedPath, renamedTo, "/fleets/9", "pilot") + } +} + +func TestFleetRenameArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id and a name"}, + {"only an id", []string{"3"}, "takes a fleet id and a name"}, + {"three arguments", []string{"3", "field", "trial"}, "takes a fleet id and a name"}, + {"a wordy id", []string{"pilot", "rooftop"}, "shown by fleet list"}, + {"an empty name", []string{"3", ""}, "takes a fleet id and a name"}, + {"a whitespace name", []string{"3", " "}, "takes a fleet id and a name"}, + } + + for _, test := range tests { + err := FleetRename(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/commands/fleet_transfer.go b/internal/commands/fleet_transfer.go new file mode 100644 index 0000000..94ce714 --- /dev/null +++ b/internal/commands/fleet_transfer.go @@ -0,0 +1,60 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func FleetTransfer(arguments []string) error { + + if len(arguments) != 2 || arguments[1] == "" { + return errors.New("fleet transfer takes a fleet id and an email address") + } + + 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") + } + + email := arguments[1] + + body, err := json.Marshal(map[string]string{"email": email}) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/owner", 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.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Transferred the fleet to %s.\n", email) + + return nil +} diff --git a/internal/commands/fleet_transfer_test.go b/internal/commands/fleet_transfer_test.go new file mode 100644 index 0000000..fdcbed1 --- /dev/null +++ b/internal/commands/fleet_transfer_test.go @@ -0,0 +1,62 @@ +package commands + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestFleetTransfer(t *testing.T) { + transferredPath := "" + transferredTo := "" + + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/owner", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Email string `json:"email"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + + transferredPath = r.URL.Path + transferredTo = body.Email + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := FleetTransfer([]string{"3", "successor@example.com"}) + + if err != nil { + t.Fatal(err) + } + + if transferredPath != "/fleets/3/owner" || transferredTo != "successor@example.com" { + t.Errorf("the server saw %q handed to %q, want %q handed to %q", + transferredPath, transferredTo, "/fleets/3/owner", "successor@example.com") + } +} + +func TestFleetTransferArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id and an email address"}, + {"only an id", []string{"3"}, "takes a fleet id and an email address"}, + {"an empty address", []string{"3", ""}, "takes a fleet id and an email address"}, + {"a wordy id", []string{"pilot", "successor@example.com"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := FleetTransfer(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/commands/fleets.go b/internal/commands/fleets.go new file mode 100644 index 0000000..6179c75 --- /dev/null +++ b/internal/commands/fleets.go @@ -0,0 +1,47 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type fleetEntry struct { + Id int64 `json:"id"` + Name string `json:"name"` + Owner bool `json:"owner"` +} + +func fetchFleets() ([]fleetEntry, error) { + request, err := authenticatedRequest(http.MethodGet, "/fleets", nil) + + if err != nil { + return nil, err + } + + response, err := apiClient.Do(request) + + if err != nil { + return nil, fmt.Errorf("the server could not be reached: %w", err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fleets := []fleetEntry{} + + err = json.NewDecoder(response.Body).Decode(&fleets) + + if err != nil { + return nil, err + } + + return fleets, nil +} diff --git a/internal/commands/fleets_test.go b/internal/commands/fleets_test.go new file mode 100644 index 0000000..7da86d2 --- /dev/null +++ b/internal/commands/fleets_test.go @@ -0,0 +1,40 @@ +package commands + +import ( + "net/http" + "os" + "strings" + "testing" +) + +func TestFetchFleetsNotLoggedIn(t *testing.T) { + isolateKeyStorage(t) + + _, err := fetchFleets() + + if err == nil || !strings.Contains(err.Error(), "not logged in") { + t.Fatalf("error = %v, want the not-logged-in hint", err) + } +} + +func TestFetchFleetsEmptyKeyFile(t *testing.T) { + loggedInTestServer(t, http.NotFoundHandler()) + + path, err := keyPath() + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte(" \n"), 0o600) + + if err != nil { + t.Fatal(err) + } + + _, err = fetchFleets() + + if err == nil || !strings.Contains(err.Error(), "not logged in") { + t.Fatalf("error = %v, want the not-logged-in hint for an empty key file", err) + } +} diff --git a/internal/commands/member_add.go b/internal/commands/member_add.go new file mode 100644 index 0000000..c21b2e9 --- /dev/null +++ b/internal/commands/member_add.go @@ -0,0 +1,60 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func MemberAdd(arguments []string) error { + + if len(arguments) != 2 || arguments[0] == "" { + return errors.New("member add takes an email address and a fleet id") + } + + email := arguments[0] + + fleetId, err := strconv.ParseInt(arguments[1], 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{"email": email}) + + if err != nil { + return err + } + + request, err := authenticatedRequest(http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", 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.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + } + + fmt.Printf("Gave %s access.\n", email) + + return nil +} diff --git a/internal/commands/member_add_test.go b/internal/commands/member_add_test.go new file mode 100644 index 0000000..91a8030 --- /dev/null +++ b/internal/commands/member_add_test.go @@ -0,0 +1,62 @@ +package commands + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestMemberAdd(t *testing.T) { + addedPath := "" + addedEmail := "" + + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/members", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Email string `json:"email"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + + addedPath = r.URL.Path + addedEmail = body.Email + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := MemberAdd([]string{"member@example.com", "3"}) + + if err != nil { + t.Fatal(err) + } + + if addedPath != "/fleets/3/members" || addedEmail != "member@example.com" { + t.Errorf("the server saw %q added at %q, want %q at %q", + addedEmail, addedPath, "member@example.com", "/fleets/3/members") + } +} + +func TestMemberAddArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an email address and a fleet id"}, + {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, + {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, + {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := MemberAdd(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/commands/member_list.go b/internal/commands/member_list.go new file mode 100644 index 0000000..ba30ed1 --- /dev/null +++ b/internal/commands/member_list.go @@ -0,0 +1,92 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +func MemberList(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("member list takes a fleet id") + } + + fleetId, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + request, err := authenticatedRequest(http.MethodGet, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", 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() + + body, err := io.ReadAll(response.Body) + + if err != nil { + return err + } + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("the server said: %s", strings.TrimSpace(string(body))) + } + + if jsonOutput { + fmt.Print(string(body)) + return nil + } + + people := struct { + Owner string `json:"owner"` + Members []string `json:"members"` + }{} + + err = json.Unmarshal(body, &people) + + if err != nil { + return err + } + + emailWidth := len(people.Owner) + + for _, email := range people.Members { + emailWidth = max(emailWidth, len(email)) + } + + fmt.Printf("%-*s owner\n", emailWidth, people.Owner) + + for _, email := range people.Members { + fmt.Printf("%-*s member\n", emailWidth, email) + } + + return nil +} diff --git a/internal/commands/member_list_test.go b/internal/commands/member_list_test.go new file mode 100644 index 0000000..7edce6f --- /dev/null +++ b/internal/commands/member_list_test.go @@ -0,0 +1,79 @@ +package commands + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +func TestMemberList(t *testing.T) { + tests := []struct { + name string + arguments []string + people string + wantError string + }{ + { + name: "the people table", + arguments: []string{"3"}, + people: `{"owner":"owner@example.com","members":["member@example.com"]}`, + }, + { + name: "nobody but the owner", + arguments: []string{"3"}, + people: `{"owner":"owner@example.com","members":[]}`, + }, + { + name: "machine-readable output", + arguments: []string{"3", "--json"}, + people: `{"owner":"owner@example.com","members":["member@example.com"]}`, + }, + { + name: "the flag before the id", + arguments: []string{"--json", "3"}, + people: `{"owner":"owner@example.com","members":[]}`, + }, + { + name: "no fleet id", + arguments: []string{"--json"}, + wantError: "takes a fleet id", + }, + { + name: "two fleet ids", + arguments: []string{"3", "4"}, + wantError: "takes a 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/{id}/members", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.people) + }) + + loggedInTestServer(t, mux) + + err := MemberList(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/member_remove.go b/internal/commands/member_remove.go new file mode 100644 index 0000000..dd3b803 --- /dev/null +++ b/internal/commands/member_remove.go @@ -0,0 +1,51 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +func MemberRemove(arguments []string) error { + + if len(arguments) != 2 || arguments[0] == "" { + return errors.New("member remove takes an email address and a fleet id") + } + + email := arguments[0] + + fleetId, err := strconv.ParseInt(arguments[1], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + request, err := authenticatedRequest(http.MethodDelete, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members/"+url.PathEscape(email), 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("Removed access for %s.\n", email) + + return nil +} diff --git a/internal/commands/member_remove_test.go b/internal/commands/member_remove_test.go new file mode 100644 index 0000000..f21bc82 --- /dev/null +++ b/internal/commands/member_remove_test.go @@ -0,0 +1,67 @@ +package commands + +import ( + "net/http" + "strings" + "testing" +) + +func TestMemberRemove(t *testing.T) { + tests := []struct { + name string + email string + }{ + {"a plain address", "member@example.com"}, + {"an address with a hash", "a#b@example.com"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + removedFleet := "" + removedEmail := "" + + mux := http.NewServeMux() + + mux.HandleFunc("DELETE /fleets/{id}/members/{email}", func(w http.ResponseWriter, r *http.Request) { + removedFleet = r.PathValue("id") + removedEmail = r.PathValue("email") + + w.WriteHeader(http.StatusNoContent) + }) + + loggedInTestServer(t, mux) + + err := MemberRemove([]string{test.email, "3"}) + + if err != nil { + t.Fatal(err) + } + + if removedFleet != "3" || removedEmail != test.email { + t.Errorf("the server saw %q removed from fleet %q, want %q from fleet %q", + removedEmail, removedFleet, test.email, "3") + } + }) + } +} + +func TestMemberRemoveArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an email address and a fleet id"}, + {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, + {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, + {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := MemberRemove(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 24ed509..758d90f 100644 --- a/main.go +++ b/main.go @@ -34,45 +34,46 @@ var sections = []section{ { title: "Fleets", commands: []command{ - {name: "fleet create", arguments: "", summary: "Create a fleet"}, - {name: "fleet list", summary: "List the fleets you can reach"}, - {name: "fleet rename", arguments: "", summary: "Rename a fleet"}, - {name: "fleet delete", summary: "Delete a fleet and release its devices"}, + {name: "fleet create", arguments: "", summary: "Create a fleet", run: commands.FleetCreate}, + {name: "fleet list", summary: "List the fleets you can reach", run: commands.FleetList}, + {name: "fleet rename", arguments: " ", summary: "Rename a fleet", run: commands.FleetRename}, + {name: "fleet transfer", arguments: " ", summary: "Hand a fleet to a new owner", run: commands.FleetTransfer}, + {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"}, - {name: "member list", summary: "List the people who can reach a fleet"}, - {name: "member remove", arguments: "", summary: "Take away someone's access"}, + {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", summary: "List devices, their state, and when they were last seen"}, - {name: "rename", arguments: "", summary: "Rename a device"}, - {name: "release", summary: "Wipe a device and hand it back"}, - {name: "start", summary: "Run the code on the target"}, - {name: "stop", summary: "Halt the code on the target"}, - {name: "restart", summary: "Restart the code on the target"}, + {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"}, }, }, { title: "Files", commands: []command{ - {name: "upload", arguments: "", summary: "Upload a file or directory to the target"}, - {name: "download", arguments: "", summary: "Download the target's files into "}, - {name: "dev", arguments: "", summary: "Upload on every change, and tail"}, + {name: "upload", arguments: " ...", summary: "Upload files or directories to the target"}, + {name: "download", arguments: " ", summary: "Download the target's files into "}, + {name: "dev", arguments: " ... [--log-file ]", summary: "Upload on every change, and tail"}, }, }, { title: "Data", commands: []command{ - {name: "tail", summary: "Stream events and logs as they arrive"}, - {name: "send", arguments: "-m ", summary: "Queue a message for the target to collect"}, + {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"}, }, }, { @@ -165,16 +166,8 @@ func printHelp(writer io.Writer) { } fmt.Fprint(writer, ` -Targets - Every command that touches devices takes --fleet or --device. With neither, - it acts on your only fleet, and stops if you can reach more than one. - Flags - --fleet Act on every device in this fleet - --device Act on one device, by name or by IMEI - --role admin or member, when adding someone to a fleet - --yes Do not ask before acting on more than one device - --json Print machine-readable output + --json Print machine-readable output Environment SUPERSTACK_API Talk to this server instead of the production one From 60460256aebcb6cf08ce6ad6489718f5b681af70 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Tue, 11 Aug 2026 12:59:47 +0200 Subject: [PATCH 3/5] Implemented upgrade: self-replacing for manual installs, deferring to the package manager elsewhere --- internal/commands/upgrade.go | 273 ++++++++++++++++++++++++++++++ internal/commands/upgrade_test.go | 271 +++++++++++++++++++++++++++++ main.go | 14 +- 3 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 internal/commands/upgrade.go create mode 100644 internal/commands/upgrade_test.go diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go new file mode 100644 index 0000000..befa8ff --- /dev/null +++ b/internal/commands/upgrade.go @@ -0,0 +1,273 @@ +package commands + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +var currentExecutable = os.Executable + +var downloadClient = &http.Client{Timeout: 5 * time.Minute} + +func Upgrade(arguments []string) error { + + if len(arguments) != 0 { + return errors.New("upgrade takes no arguments") + } + + executable, err := currentExecutable() + + if err != nil { + return err + } + + executable, err = filepath.EvalSymlinks(executable) + + if err != nil { + return err + } + + // A package manager owns what it installed, and the nix store is + // read-only, so upgrade replaces only binaries it owns itself + switch { + case strings.Contains(executable, "/nix/store/"): + return errors.New("this install came from nix, update it through nix instead") + + case strings.Contains(executable, "/Cellar/") || strings.Contains(executable, "/Caskroom/"): + return errors.New("this install came from homebrew, update it with: brew upgrade superstack") + + case strings.Contains(strings.ToLower(executable), filepath.Join("scoop", "apps")): + return errors.New("this install came from scoop, update it with: scoop update superstack") + + case executable == "/usr/bin/superstack": + return errors.New("this install came from your system's package manager, update it there instead") + } + + // The newest release is wherever github's latest redirect lands, the + // same trick install.sh uses, so no API and no rate limit + latestRequest, err := http.NewRequest(http.MethodHead, + githubBase+"/siliconwitchery/superstack-cli/releases/latest", nil) + + if err != nil { + return err + } + + latestResponse, err := downloadClient.Do(latestRequest) + + if err != nil { + return fmt.Errorf("github could not be reached: %w", err) + } + + latestResponse.Body.Close() + + landed := latestResponse.Request.URL.Path + + tag := "" + + if index := strings.LastIndex(landed, "/releases/tag/"); index >= 0 { + tag = landed[index+len("/releases/tag/"):] + } + + if tag == "" { + return errors.New("no published release was found") + } + + latestVersion := strings.TrimPrefix(tag, "v") + + if latestVersion == CliVersion { + fmt.Println("You already have the latest release.") + return nil + } + + archiveName := fmt.Sprintf("superstack_%s_%s_%s.tar.gz", latestVersion, runtime.GOOS, runtime.GOARCH) + + binaryName := "superstack" + + if runtime.GOOS == "windows" { + archiveName = fmt.Sprintf("superstack_%s_windows_%s.zip", latestVersion, runtime.GOARCH) + + binaryName = "superstack.exe" + } + + downloadBase := githubBase + "/siliconwitchery/superstack-cli/releases/download/" + tag + + archiveResponse, err := downloadClient.Get(downloadBase + "/" + archiveName) + + if err != nil { + return fmt.Errorf("github could not be reached: %w", err) + } + + defer archiveResponse.Body.Close() + + if archiveResponse.StatusCode != http.StatusOK { + return errors.New("the release has no download for this computer") + } + + archiveBytes, err := io.ReadAll(archiveResponse.Body) + + if err != nil { + return err + } + + checksumsResponse, err := downloadClient.Get(downloadBase + "/checksums.txt") + + if err != nil { + return fmt.Errorf("github could not be reached: %w", err) + } + + defer checksumsResponse.Body.Close() + + if checksumsResponse.StatusCode != http.StatusOK { + return errors.New("the release is missing its checksums") + } + + checksums, err := io.ReadAll(io.LimitReader(checksumsResponse.Body, 1<<20)) + + if err != nil { + return err + } + + archiveHash := sha256.Sum256(archiveBytes) + + wantHash := hex.EncodeToString(archiveHash[:]) + + verified := false + + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + + if len(fields) == 2 && fields[1] == archiveName && fields[0] == wantHash { + verified = true + } + } + + if !verified { + return errors.New("the download did not match the release's checksum, try again") + } + + // Pull the binary out of the archive + binaryBytes := []byte(nil) + + if runtime.GOOS == "windows" { + zipReader, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) + + if err != nil { + return err + } + + for _, file := range zipReader.File { + if file.Name != binaryName { + continue + } + + opened, err := file.Open() + + if err != nil { + return err + } + + binaryBytes, err = io.ReadAll(opened) + + opened.Close() + + if err != nil { + return err + } + } + } else { + gzipReader, err := gzip.NewReader(bytes.NewReader(archiveBytes)) + + if err != nil { + return err + } + + tarReader := tar.NewReader(gzipReader) + + for { + header, err := tarReader.Next() + + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return err + } + + if header.Name != binaryName { + continue + } + + binaryBytes, err = io.ReadAll(tarReader) + + if err != nil { + return err + } + } + } + + if len(binaryBytes) == 0 { + return errors.New("the release download is missing the binary") + } + + // Swap the new binary in + temporary, err := os.CreateTemp(filepath.Dir(executable), ".superstack-upgrade-") + + if err != nil { + return errors.New("this install cannot be replaced from here, rerun the install script instead") + } + + _, err = temporary.Write(binaryBytes) + + if err == nil { + err = temporary.Chmod(0o755) + } + + if closeError := temporary.Close(); err == nil { + err = closeError + } + + if err != nil { + os.Remove(temporary.Name()) + return err + } + + // Windows cannot overwrite a running binary but can rename it aside, so + // the swap goes through a .old that the next upgrade clears + previous := executable + ".old" + + os.Remove(previous) + + err = os.Rename(executable, previous) + + if err != nil { + os.Remove(temporary.Name()) + return errors.New("this install cannot be replaced from here, rerun the install script instead") + } + + err = os.Rename(temporary.Name(), executable) + + if err != nil { + os.Rename(previous, executable) + return err + } + + os.Remove(previous) + + fmt.Printf("Upgraded superstack %s to %s.\n", CliVersion, latestVersion) + + return nil +} diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go new file mode 100644 index 0000000..9c46bee --- /dev/null +++ b/internal/commands/upgrade_test.go @@ -0,0 +1,271 @@ +package commands + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func fakeExecutable(t *testing.T, path string) { + t.Helper() + + previous := currentExecutable + + currentExecutable = func() (string, error) { return path, nil } + + t.Cleanup(func() { currentExecutable = previous }) +} + +func fakeCliVersion(t *testing.T, version string) { + t.Helper() + + previous := CliVersion + + CliVersion = version + + t.Cleanup(func() { CliVersion = previous }) +} + +func fakeRelease(t *testing.T, version string, binaryContent string, tampered bool) { + t.Helper() + + archiveName := fmt.Sprintf("superstack_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) + + var archive bytes.Buffer + + gzipWriter := gzip.NewWriter(&archive) + + tarWriter := tar.NewWriter(gzipWriter) + + err := tarWriter.WriteHeader(&tar.Header{Name: "superstack", Mode: 0o755, Size: int64(len(binaryContent))}) + + if err != nil { + t.Fatal(err) + } + + _, err = tarWriter.Write([]byte(binaryContent)) + + if err != nil { + t.Fatal(err) + } + + tarWriter.Close() + gzipWriter.Close() + + hash := sha256.Sum256(archive.Bytes()) + + if tampered { + hash = sha256.Sum256([]byte("something else entirely")) + } + + mux := http.NewServeMux() + + server := httptest.NewServer(mux) + + t.Cleanup(server.Close) + + mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, server.URL+"/siliconwitchery/superstack-cli/releases/tag/v"+version, http.StatusFound) + }) + + mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/download/v"+version+"/"+archiveName, + func(w http.ResponseWriter, r *http.Request) { + w.Write(archive.Bytes()) + }) + + mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/download/v"+version+"/checksums.txt", + func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(hash[:]), archiveName) + }) + + previousBase := githubBase + + githubBase = server.URL + + t.Cleanup(func() { githubBase = previousBase }) +} + +func TestUpgrade(t *testing.T) { + directory := t.TempDir() + + executable := filepath.Join(directory, "superstack") + + err := os.WriteFile(executable, []byte("the old binary"), 0o755) + + if err != nil { + t.Fatal(err) + } + + fakeExecutable(t, executable) + + fakeCliVersion(t, "1.0.0") + + fakeRelease(t, "9.9.9", "the new binary", false) + + err = Upgrade(nil) + + if err != nil { + t.Fatal(err) + } + + replaced, err := os.ReadFile(executable) + + if err != nil { + t.Fatal(err) + } + + if string(replaced) != "the new binary" { + t.Errorf("the binary now holds %q, want the new release", replaced) + } + + info, err := os.Stat(executable) + + if err != nil { + t.Fatal(err) + } + + if info.Mode().Perm() != 0o755 { + t.Errorf("the binary's mode is %v, want 0755", info.Mode().Perm()) + } + + if _, statError := os.Stat(executable + ".old"); !os.IsNotExist(statError) { + t.Error("the renamed-aside binary was left behind") + } +} + +func TestUpgradeAlreadyLatest(t *testing.T) { + directory := t.TempDir() + + executable := filepath.Join(directory, "superstack") + + err := os.WriteFile(executable, []byte("the current binary"), 0o755) + + if err != nil { + t.Fatal(err) + } + + fakeExecutable(t, executable) + + fakeCliVersion(t, "9.9.9") + + fakeRelease(t, "9.9.9", "the same binary", false) + + err = Upgrade(nil) + + if err != nil { + t.Fatal(err) + } + + kept, err := os.ReadFile(executable) + + if err != nil { + t.Fatal(err) + } + + if string(kept) != "the current binary" { + t.Errorf("the binary now holds %q, want it untouched", kept) + } +} + +func TestUpgradeChecksumMismatch(t *testing.T) { + directory := t.TempDir() + + executable := filepath.Join(directory, "superstack") + + err := os.WriteFile(executable, []byte("the old binary"), 0o755) + + if err != nil { + t.Fatal(err) + } + + fakeExecutable(t, executable) + + fakeCliVersion(t, "1.0.0") + + fakeRelease(t, "9.9.9", "the new binary", true) + + err = Upgrade(nil) + + if err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("error = %v, want the checksum refusal", err) + } + + kept, err := os.ReadFile(executable) + + if err != nil { + t.Fatal(err) + } + + if string(kept) != "the old binary" { + t.Errorf("the binary now holds %q, want it untouched after the failed checksum", kept) + } +} + +func TestUpgradeManagedInstalls(t *testing.T) { + tests := []struct { + name string + pathParts []string + wantHint string + }{ + {"nix", []string{"nix", "store", "abc123-superstack", "bin", "superstack"}, "nix"}, + {"homebrew cellar", []string{"Cellar", "superstack", "1.0.0", "bin", "superstack"}, "homebrew"}, + {"homebrew caskroom", []string{"Caskroom", "superstack", "1.0.0", "superstack"}, "homebrew"}, + {"scoop", []string{"scoop", "apps", "superstack", "current", "superstack.exe"}, "scoop"}, + } + + unreachable := httptest.NewServer(http.NotFoundHandler()) + + unreachable.Close() + + previousBase := githubBase + + githubBase = unreachable.URL + + t.Cleanup(func() { githubBase = previousBase }) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + + path := filepath.Join(append([]string{root}, test.pathParts...)...) + + err := os.MkdirAll(filepath.Dir(path), 0o755) + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte("managed"), 0o755) + + if err != nil { + t.Fatal(err) + } + + fakeExecutable(t, path) + + err = Upgrade(nil) + + if err == nil || !strings.Contains(err.Error(), test.wantHint) { + t.Fatalf("error = %v, want it to point at %s", err, test.wantHint) + } + }) + } +} + +func TestUpgradeTakesNoArguments(t *testing.T) { + err := Upgrade([]string{"now"}) + + if err == nil || !strings.Contains(err.Error(), "takes no arguments") { + t.Fatalf("error = %v, want the no-arguments hint", err) + } +} diff --git a/main.go b/main.go index 758d90f..7727862 100644 --- a/main.go +++ b/main.go @@ -87,7 +87,7 @@ var sections = []section{ { title: "Superstack", commands: []command{ - {name: "upgrade", summary: "Replace this binary with the latest release"}, + {name: "upgrade", summary: "Replace this binary with the latest release", run: commands.Upgrade}, {name: "version", summary: "Show the version"}, {name: "help", arguments: "[command]", summary: "Show this help, or help for one command"}, }, @@ -239,11 +239,15 @@ func main() { os.Exit(1) } - err = commands.CheckServer() + // Upgrade talks to GitHub rather than the server, and must keep working + // while the server's version gate is refusing this build + if entry.name != "upgrade" { + err = commands.CheckServer() - if err != nil { - fmt.Fprintf(os.Stderr, "superstack: %s\n", err) - os.Exit(1) + if err != nil { + fmt.Fprintf(os.Stderr, "superstack: %s\n", err) + os.Exit(1) + } } err = entry.run(rest) From 2e3b18c781b07d2f5bc74a24aca84832b1f7f8dc Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Tue, 11 Aug 2026 13:13:40 +0200 Subject: [PATCH 4/5] Removed the upgrade command, install.sh, and the winget publisher: each install channel owns its own updates --- .github/workflows/release.yml | 7 - .goreleaser.yaml | 30 ---- CLAUDE.md | 40 +++-- README.md | 21 --- install.sh | 70 -------- internal/commands/upgrade.go | 273 ------------------------------ internal/commands/upgrade_test.go | 271 ----------------------------- main.go | 13 +- 8 files changed, 23 insertions(+), 702 deletions(-) delete mode 100755 install.sh delete mode 100644 internal/commands/upgrade.go delete mode 100644 internal/commands/upgrade_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc657ec..dc88ef6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,6 @@ jobs: - name: Verify the publishing credentials are present env: TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} AUR_KEY: ${{ secrets.AUR_KEY }} run: | if [ -z "$TAP_GITHUB_TOKEN" ]; then @@ -61,11 +60,6 @@ jobs: exit 1 fi - if [ -z "$WINGET_GITHUB_TOKEN" ]; then - echo "WINGET_GITHUB_TOKEN is unset, and GoReleaser would skip the winget pull request without failing" - exit 1 - fi - if [ -z "$AUR_KEY" ]; then echo "AUR_KEY is unset, and GoReleaser would skip the AUR upload without failing" exit 1 @@ -79,5 +73,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} - WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} AUR_KEY: ${{ secrets.AUR_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 566b202..ad727dc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -118,33 +118,3 @@ aurs: install -Dm755 "./superstack" "${pkgdir}/usr/bin/superstack" install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 "./README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md" - -winget: - - name: superstack - ids: - - superstack - package_identifier: SiliconWitchery.Superstack - publisher: Silicon Witchery - publisher_url: https://siliconwitchery.com - publisher_support_url: https://github.com/siliconwitchery/superstack-cli/issues - homepage: https://github.com/siliconwitchery/superstack-cli - short_description: The Superstack command line interface - license: ISC - license_url: https://github.com/siliconwitchery/superstack-cli/blob/main/LICENSE - copyright: Silicon Witchery AB - skip_upload: auto - repository: - owner: siliconwitchery - name: winget-pkgs - branch: superstack-{{ .Version }} - token: "{{ .Env.WINGET_GITHUB_TOKEN }}" - pull_request: - enabled: true - draft: false - base: - owner: microsoft - name: winget-pkgs - branch: master - commit_author: - name: Raj Nakarja - email: raj@siliconwitchery.com diff --git a/CLAUDE.md b/CLAUDE.md index 4cdec9e..62ce910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,19 +131,21 @@ where it can read the history. Pushing a `v*` tag is the whole release. GoReleaser builds the static binaries for Linux, macOS, and Windows, publishes the GitHub release with checksums, pushes the Homebrew cask to `siliconwitchery/homebrew-tap`, pushes the Scoop -manifest to `siliconwitchery/scoop-bucket`, pushes the `superstack-bin` -PKGBUILD to the AUR, and opens a winget pull request against -`microsoft/winget-pkgs` from the fork at `siliconwitchery/winget-pkgs`. The -`install.sh` at the repo root installs straight from GitHub Releases and needs -no per-release attention. - -The flake is the fifth distribution path, and the only one not driven by a -tag. It builds from source at whatever commit the user points it at, so it -needs no release to work. Superstack is not in nixpkgs and will not be until -the project has traction, so the flake is how Nix users install until then. -`flake.nix` reads the version straight out of `main.go`, which keeps the -single source of truth intact, and renames the binary in `postInstall` -because Go names it after the module path rather than after the command. +manifest to `siliconwitchery/scoop-bucket`, and pushes the `superstack-bin` +PKGBUILD to the AUR. There is deliberately no winget package (its pull +requests review too slowly for the version gate's forced upgrades, and scoop +is the standard channel for developer tools), no `install.sh` (manual +installs are a download from the releases page, unpacked onto the PATH), and +no self-update command in the CLI (each channel updates itself; the server's +version gate is what prompts users to do so). + +The flake is the one distribution path not driven by a tag. It builds from +source at whatever commit the user points it at, so it needs no release to +work. Superstack is not in nixpkgs and will not be until the project has +traction, so the flake is how Nix users install until then. `flake.nix` +reads the version straight out of `main.go`, which keeps the single source +of truth intact, and renames the binary in `postInstall` because Go names it +after the module path rather than after the command. `.goreleaser.yaml` is the only description of the build matrix. Nothing else may restate it, because a second copy drifts. CI proves the release path by @@ -153,17 +155,13 @@ that config. Every publisher carries `skip_upload: auto`, so a tag with a prerelease suffix publishes a GitHub release and touches no package manager. That is how the -pipeline gets exercised without shipping. It leaves the four publisher pushes +pipeline gets exercised without shipping. It leaves the three publisher pushes themselves untested, which only a real tag proves. -Three credentials sit behind the release, and the workflow's guard checks only +Two credentials sit behind the release, and the workflow's guard checks only that they are present, never that they work. `TAP_GITHUB_TOKEN` is -fine-grained and scoped to the tap and the bucket. `WINGET_GITHUB_TOKEN` has to -be a classic token, because a fine-grained one cannot reach a repository -outside its resource owner and so cannot open the pull request against -Microsoft. `AUR_KEY` is a passphraseless SSH key registered with an AUR -account. GoReleaser reports a failed winget pull request without failing the -run, so that step needs checking by hand after a release. +fine-grained and scoped to the tap and the bucket. `AUR_KEY` is a +passphraseless SSH key registered with an AUR account. The generated changelog is deliberately disabled, so a fresh release starts with an empty body. Write the notes into it afterwards; GoReleaser keeps an diff --git a/README.md b/README.md index 3fafcff..3a1b60e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ a separate project; this repo is the CLI only. It is laid out as follows: ├── flake.lock # Pins nixpkgs ├── flake.nix # The superstack package, and the dev shell ├── go.mod -├── install.sh # curl-to-shell installer for Linux and macOS ├── internal │ └── commands # One file per command, plus the shared server client ├── LICENSE @@ -28,25 +27,12 @@ a separate project; this repo is the CLI only. It is laid out as follows: Each option needs a published release. -- **Linux and macOS.** Pin a version by putting `VERSION=1.2.3` on the `sh` - side of the pipe. - - ```sh - curl -fsSL https://raw.githubusercontent.com/siliconwitchery/superstack-cli/main/install.sh | sh - ``` - - **Homebrew:** ```sh brew install --cask siliconwitchery/tap/superstack ``` -- **winget:** - - ```sh - winget install SiliconWitchery.Superstack - ``` - - **Scoop:** ```sh @@ -99,8 +85,6 @@ Do everything below once. 1. Create public repositories `siliconwitchery/homebrew-tap` and `siliconwitchery/scoop-bucket`, each with a README. -1. Fork `microsoft/winget-pkgs` into `siliconwitchery`. - 1. Add a fine-grained token (Settings > Developer settings > Personal access tokens) as the Actions secret `TAP_GITHUB_TOKEN`: @@ -108,9 +92,6 @@ Do everything below once. - Repository access: `homebrew-tap` and `scoop-bucket` - Permissions: Contents, read and write -1. Add a classic token with the `public_repo` scope as the Actions secret - `WINGET_GITHUB_TOKEN`. A fine-grained token cannot open the pull request. - 1. Register at [aur.archlinux.org](https://aur.archlinux.org/register), then: ```sh @@ -147,7 +128,5 @@ Do everything below once. 1. Write the release notes into the empty release body on GitHub, following the shape in CLAUDE.md. -1. Check that the winget pull request opened against `microsoft/winget-pkgs`. - A tag carrying a prerelease suffix, `v0.0.2-rc1`, publishes a GitHub prerelease and skips every package manager. Tags cannot be moved or deleted. diff --git a/install.sh b/install.sh deleted file mode 100755 index 6b6bd92..0000000 --- a/install.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/sh - -set -eu - -repository="siliconwitchery/superstack-cli" - -platform=$(uname -s) - -case "$platform" in - Linux) platform="linux" ;; - Darwin) platform="darwin" ;; - *) echo "Unsupported operating system: $platform" >&2; exit 1 ;; -esac - -architecture=$(uname -m) - -case "$architecture" in - x86_64) architecture="amd64" ;; - aarch64 | arm64) architecture="arm64" ;; - *) echo "Unsupported architecture: $architecture" >&2; exit 1 ;; -esac - -tag="${VERSION:-}" - -if [ -z "$tag" ]; then - latest=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$repository/releases/latest") - - case "$latest" in - */releases/tag/*) tag="${latest##*/}" ;; - *) echo "No published release found for $repository" >&2; exit 1 ;; - esac -fi - -case "$tag" in - v*) ;; - *) tag="v$tag" ;; -esac - -version="${tag#v}" - -archive="superstack_${version}_${platform}_${architecture}.tar.gz" -download="https://github.com/$repository/releases/download/$tag" - -workdir=$(mktemp -d) -trap 'rm -rf "$workdir"' EXIT -trap 'exit 1' INT TERM HUP - -curl -fsSL "$download/$archive" -o "$workdir/$archive" -curl -fsSL "$download/checksums.txt" -o "$workdir/checksums.txt" - -cd "$workdir" - -awk -v name="$archive" '$2 == name' checksums.txt > expected.txt - -if command -v sha256sum > /dev/null; then - sha256sum -c expected.txt > /dev/null -else - shasum -a 256 -c expected.txt > /dev/null -fi - -tar -xzf "$archive" superstack - -if [ -w /usr/local/bin ]; then - install -m 755 superstack /usr/local/bin/superstack -else - sudo mkdir -p /usr/local/bin - sudo install -m 755 superstack /usr/local/bin/superstack -fi - -echo "Installed superstack $version to /usr/local/bin/superstack" diff --git a/internal/commands/upgrade.go b/internal/commands/upgrade.go deleted file mode 100644 index befa8ff..0000000 --- a/internal/commands/upgrade.go +++ /dev/null @@ -1,273 +0,0 @@ -package commands - -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "time" -) - -var currentExecutable = os.Executable - -var downloadClient = &http.Client{Timeout: 5 * time.Minute} - -func Upgrade(arguments []string) error { - - if len(arguments) != 0 { - return errors.New("upgrade takes no arguments") - } - - executable, err := currentExecutable() - - if err != nil { - return err - } - - executable, err = filepath.EvalSymlinks(executable) - - if err != nil { - return err - } - - // A package manager owns what it installed, and the nix store is - // read-only, so upgrade replaces only binaries it owns itself - switch { - case strings.Contains(executable, "/nix/store/"): - return errors.New("this install came from nix, update it through nix instead") - - case strings.Contains(executable, "/Cellar/") || strings.Contains(executable, "/Caskroom/"): - return errors.New("this install came from homebrew, update it with: brew upgrade superstack") - - case strings.Contains(strings.ToLower(executable), filepath.Join("scoop", "apps")): - return errors.New("this install came from scoop, update it with: scoop update superstack") - - case executable == "/usr/bin/superstack": - return errors.New("this install came from your system's package manager, update it there instead") - } - - // The newest release is wherever github's latest redirect lands, the - // same trick install.sh uses, so no API and no rate limit - latestRequest, err := http.NewRequest(http.MethodHead, - githubBase+"/siliconwitchery/superstack-cli/releases/latest", nil) - - if err != nil { - return err - } - - latestResponse, err := downloadClient.Do(latestRequest) - - if err != nil { - return fmt.Errorf("github could not be reached: %w", err) - } - - latestResponse.Body.Close() - - landed := latestResponse.Request.URL.Path - - tag := "" - - if index := strings.LastIndex(landed, "/releases/tag/"); index >= 0 { - tag = landed[index+len("/releases/tag/"):] - } - - if tag == "" { - return errors.New("no published release was found") - } - - latestVersion := strings.TrimPrefix(tag, "v") - - if latestVersion == CliVersion { - fmt.Println("You already have the latest release.") - return nil - } - - archiveName := fmt.Sprintf("superstack_%s_%s_%s.tar.gz", latestVersion, runtime.GOOS, runtime.GOARCH) - - binaryName := "superstack" - - if runtime.GOOS == "windows" { - archiveName = fmt.Sprintf("superstack_%s_windows_%s.zip", latestVersion, runtime.GOARCH) - - binaryName = "superstack.exe" - } - - downloadBase := githubBase + "/siliconwitchery/superstack-cli/releases/download/" + tag - - archiveResponse, err := downloadClient.Get(downloadBase + "/" + archiveName) - - if err != nil { - return fmt.Errorf("github could not be reached: %w", err) - } - - defer archiveResponse.Body.Close() - - if archiveResponse.StatusCode != http.StatusOK { - return errors.New("the release has no download for this computer") - } - - archiveBytes, err := io.ReadAll(archiveResponse.Body) - - if err != nil { - return err - } - - checksumsResponse, err := downloadClient.Get(downloadBase + "/checksums.txt") - - if err != nil { - return fmt.Errorf("github could not be reached: %w", err) - } - - defer checksumsResponse.Body.Close() - - if checksumsResponse.StatusCode != http.StatusOK { - return errors.New("the release is missing its checksums") - } - - checksums, err := io.ReadAll(io.LimitReader(checksumsResponse.Body, 1<<20)) - - if err != nil { - return err - } - - archiveHash := sha256.Sum256(archiveBytes) - - wantHash := hex.EncodeToString(archiveHash[:]) - - verified := false - - for _, line := range strings.Split(string(checksums), "\n") { - fields := strings.Fields(line) - - if len(fields) == 2 && fields[1] == archiveName && fields[0] == wantHash { - verified = true - } - } - - if !verified { - return errors.New("the download did not match the release's checksum, try again") - } - - // Pull the binary out of the archive - binaryBytes := []byte(nil) - - if runtime.GOOS == "windows" { - zipReader, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) - - if err != nil { - return err - } - - for _, file := range zipReader.File { - if file.Name != binaryName { - continue - } - - opened, err := file.Open() - - if err != nil { - return err - } - - binaryBytes, err = io.ReadAll(opened) - - opened.Close() - - if err != nil { - return err - } - } - } else { - gzipReader, err := gzip.NewReader(bytes.NewReader(archiveBytes)) - - if err != nil { - return err - } - - tarReader := tar.NewReader(gzipReader) - - for { - header, err := tarReader.Next() - - if errors.Is(err, io.EOF) { - break - } - - if err != nil { - return err - } - - if header.Name != binaryName { - continue - } - - binaryBytes, err = io.ReadAll(tarReader) - - if err != nil { - return err - } - } - } - - if len(binaryBytes) == 0 { - return errors.New("the release download is missing the binary") - } - - // Swap the new binary in - temporary, err := os.CreateTemp(filepath.Dir(executable), ".superstack-upgrade-") - - if err != nil { - return errors.New("this install cannot be replaced from here, rerun the install script instead") - } - - _, err = temporary.Write(binaryBytes) - - if err == nil { - err = temporary.Chmod(0o755) - } - - if closeError := temporary.Close(); err == nil { - err = closeError - } - - if err != nil { - os.Remove(temporary.Name()) - return err - } - - // Windows cannot overwrite a running binary but can rename it aside, so - // the swap goes through a .old that the next upgrade clears - previous := executable + ".old" - - os.Remove(previous) - - err = os.Rename(executable, previous) - - if err != nil { - os.Remove(temporary.Name()) - return errors.New("this install cannot be replaced from here, rerun the install script instead") - } - - err = os.Rename(temporary.Name(), executable) - - if err != nil { - os.Rename(previous, executable) - return err - } - - os.Remove(previous) - - fmt.Printf("Upgraded superstack %s to %s.\n", CliVersion, latestVersion) - - return nil -} diff --git a/internal/commands/upgrade_test.go b/internal/commands/upgrade_test.go deleted file mode 100644 index 9c46bee..0000000 --- a/internal/commands/upgrade_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package commands - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func fakeExecutable(t *testing.T, path string) { - t.Helper() - - previous := currentExecutable - - currentExecutable = func() (string, error) { return path, nil } - - t.Cleanup(func() { currentExecutable = previous }) -} - -func fakeCliVersion(t *testing.T, version string) { - t.Helper() - - previous := CliVersion - - CliVersion = version - - t.Cleanup(func() { CliVersion = previous }) -} - -func fakeRelease(t *testing.T, version string, binaryContent string, tampered bool) { - t.Helper() - - archiveName := fmt.Sprintf("superstack_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) - - var archive bytes.Buffer - - gzipWriter := gzip.NewWriter(&archive) - - tarWriter := tar.NewWriter(gzipWriter) - - err := tarWriter.WriteHeader(&tar.Header{Name: "superstack", Mode: 0o755, Size: int64(len(binaryContent))}) - - if err != nil { - t.Fatal(err) - } - - _, err = tarWriter.Write([]byte(binaryContent)) - - if err != nil { - t.Fatal(err) - } - - tarWriter.Close() - gzipWriter.Close() - - hash := sha256.Sum256(archive.Bytes()) - - if tampered { - hash = sha256.Sum256([]byte("something else entirely")) - } - - mux := http.NewServeMux() - - server := httptest.NewServer(mux) - - t.Cleanup(server.Close) - - mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/latest", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, server.URL+"/siliconwitchery/superstack-cli/releases/tag/v"+version, http.StatusFound) - }) - - mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/download/v"+version+"/"+archiveName, - func(w http.ResponseWriter, r *http.Request) { - w.Write(archive.Bytes()) - }) - - mux.HandleFunc("GET /siliconwitchery/superstack-cli/releases/download/v"+version+"/checksums.txt", - func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(hash[:]), archiveName) - }) - - previousBase := githubBase - - githubBase = server.URL - - t.Cleanup(func() { githubBase = previousBase }) -} - -func TestUpgrade(t *testing.T) { - directory := t.TempDir() - - executable := filepath.Join(directory, "superstack") - - err := os.WriteFile(executable, []byte("the old binary"), 0o755) - - if err != nil { - t.Fatal(err) - } - - fakeExecutable(t, executable) - - fakeCliVersion(t, "1.0.0") - - fakeRelease(t, "9.9.9", "the new binary", false) - - err = Upgrade(nil) - - if err != nil { - t.Fatal(err) - } - - replaced, err := os.ReadFile(executable) - - if err != nil { - t.Fatal(err) - } - - if string(replaced) != "the new binary" { - t.Errorf("the binary now holds %q, want the new release", replaced) - } - - info, err := os.Stat(executable) - - if err != nil { - t.Fatal(err) - } - - if info.Mode().Perm() != 0o755 { - t.Errorf("the binary's mode is %v, want 0755", info.Mode().Perm()) - } - - if _, statError := os.Stat(executable + ".old"); !os.IsNotExist(statError) { - t.Error("the renamed-aside binary was left behind") - } -} - -func TestUpgradeAlreadyLatest(t *testing.T) { - directory := t.TempDir() - - executable := filepath.Join(directory, "superstack") - - err := os.WriteFile(executable, []byte("the current binary"), 0o755) - - if err != nil { - t.Fatal(err) - } - - fakeExecutable(t, executable) - - fakeCliVersion(t, "9.9.9") - - fakeRelease(t, "9.9.9", "the same binary", false) - - err = Upgrade(nil) - - if err != nil { - t.Fatal(err) - } - - kept, err := os.ReadFile(executable) - - if err != nil { - t.Fatal(err) - } - - if string(kept) != "the current binary" { - t.Errorf("the binary now holds %q, want it untouched", kept) - } -} - -func TestUpgradeChecksumMismatch(t *testing.T) { - directory := t.TempDir() - - executable := filepath.Join(directory, "superstack") - - err := os.WriteFile(executable, []byte("the old binary"), 0o755) - - if err != nil { - t.Fatal(err) - } - - fakeExecutable(t, executable) - - fakeCliVersion(t, "1.0.0") - - fakeRelease(t, "9.9.9", "the new binary", true) - - err = Upgrade(nil) - - if err == nil || !strings.Contains(err.Error(), "checksum") { - t.Fatalf("error = %v, want the checksum refusal", err) - } - - kept, err := os.ReadFile(executable) - - if err != nil { - t.Fatal(err) - } - - if string(kept) != "the old binary" { - t.Errorf("the binary now holds %q, want it untouched after the failed checksum", kept) - } -} - -func TestUpgradeManagedInstalls(t *testing.T) { - tests := []struct { - name string - pathParts []string - wantHint string - }{ - {"nix", []string{"nix", "store", "abc123-superstack", "bin", "superstack"}, "nix"}, - {"homebrew cellar", []string{"Cellar", "superstack", "1.0.0", "bin", "superstack"}, "homebrew"}, - {"homebrew caskroom", []string{"Caskroom", "superstack", "1.0.0", "superstack"}, "homebrew"}, - {"scoop", []string{"scoop", "apps", "superstack", "current", "superstack.exe"}, "scoop"}, - } - - unreachable := httptest.NewServer(http.NotFoundHandler()) - - unreachable.Close() - - previousBase := githubBase - - githubBase = unreachable.URL - - t.Cleanup(func() { githubBase = previousBase }) - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - - path := filepath.Join(append([]string{root}, test.pathParts...)...) - - err := os.MkdirAll(filepath.Dir(path), 0o755) - - if err != nil { - t.Fatal(err) - } - - err = os.WriteFile(path, []byte("managed"), 0o755) - - if err != nil { - t.Fatal(err) - } - - fakeExecutable(t, path) - - err = Upgrade(nil) - - if err == nil || !strings.Contains(err.Error(), test.wantHint) { - t.Fatalf("error = %v, want it to point at %s", err, test.wantHint) - } - }) - } -} - -func TestUpgradeTakesNoArguments(t *testing.T) { - err := Upgrade([]string{"now"}) - - if err == nil || !strings.Contains(err.Error(), "takes no arguments") { - t.Fatalf("error = %v, want the no-arguments hint", err) - } -} diff --git a/main.go b/main.go index 7727862..f90e4fd 100644 --- a/main.go +++ b/main.go @@ -87,7 +87,6 @@ var sections = []section{ { title: "Superstack", commands: []command{ - {name: "upgrade", summary: "Replace this binary with the latest release", run: commands.Upgrade}, {name: "version", summary: "Show the version"}, {name: "help", arguments: "[command]", summary: "Show this help, or help for one command"}, }, @@ -239,15 +238,11 @@ func main() { os.Exit(1) } - // Upgrade talks to GitHub rather than the server, and must keep working - // while the server's version gate is refusing this build - if entry.name != "upgrade" { - err = commands.CheckServer() + err = commands.CheckServer() - if err != nil { - fmt.Fprintf(os.Stderr, "superstack: %s\n", err) - os.Exit(1) - } + if err != nil { + fmt.Fprintf(os.Stderr, "superstack: %s\n", err) + os.Exit(1) } err = entry.run(rest) From dccd384369d8c74ebb9881845da3860f4e5929f9 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Tue, 11 Aug 2026 13:18:26 +0200 Subject: [PATCH 5/5] Removed the installer check that outlived install.sh --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d046d2b..59d4d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,9 +55,6 @@ jobs: - run: go test ./... - - name: Check the installer - run: shellcheck install.sh - - name: Build the release the same way a tag would uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -66,5 +63,4 @@ jobs: args: release --snapshot --clean env: TAP_GITHUB_TOKEN: snapshots-publish-nothing - WINGET_GITHUB_TOKEN: snapshots-publish-nothing AUR_KEY: snapshots-publish-nothing