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
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ release:
owner: siliconwitchery
name: superstack-cli
mode: keep-existing
replace_existing_artifacts: true
prerelease: auto

homebrew_casks:
Expand Down
57 changes: 46 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ the entire git history ships.
`~/projects/superstack-server` (github.com/siliconwitchery/superstack-server) and
owns the JSON API this binary speaks. A change on either side of the wire
usually implies one on the other, so read its CLAUDE.md before changing
anything that crosses it. The two files share the coding principles below
verbatim; a change to one belongs in both.
anything that crosses it. The two files share the coding principles and the
development cycle below verbatim; a change to one belongs in both.

## Coding principles

Expand Down Expand Up @@ -79,11 +79,45 @@ General and meant to be reused verbatim across projects.
too, so neither reaches for a C toolchain that need not exist.
- Keep the dependency set small: nothing a distribution's packager would balk
at.
- Development happens on the `dev` branch of both repositories; check the
current branch before the first edit, and never resume a branch that has
been merged and deleted.
- During development the CLI version is the next 0.0.x above the latest
release tag, and the server's minimum-version gate matches it exactly.
- Development happens on `dev` in both repositories, created fresh from `main`
at the start of every cycle and never resumed: merging squashes the branch
and deletes it, so any local copy left behind is permanently diverged.
- The CLI version changes only in a release pull request. The server's minimum
version is an independent compatibility floor, not the current CLI version;
raise it only for an incompatible API change, after the required CLI release
is available through its package channels.

## Development cycle

One request, one branch, one pull request, and the branch never outlives it.
Raj owns steps 1, 5 and 7; the rest happen here.

1. **Raj asks for something.** A cycle starts from a request, never from
picking up where the last one stopped.
2. **Create `dev` fresh from `main`:** `git fetch origin`, then
`git switch -C dev origin/main`. Never resume an existing `dev`. Merging
squashes the branch and deletes it, so a local copy left behind is
permanently diverged, and its next pull request conflicts on every line it
touches.
3. **Work, then iterate on the feedback.**
4. **Open the pull request.** Product work does not change the CLI version or
the server's minimum-version gate. If an incompatible API change needs a
higher floor, keep the server compatible until the release in step 8 is
installable, then raise the gate in a server pull request.
5. **Raj reviews and merges on GitHub.** Squash only.
6. **Return to `main`:** `git switch main`, `git pull`, `git branch -D dev`,
so nothing stale is left to resume.
7. **Raj may ask for a release.** A merged cycle is not automatically one.
8. **Cut releases independently.** For the CLI, open and merge a release pull
request that changes its version, then tag that commit. A server-only
release needs no CLI release. For an incompatible API change, publish the
compatible CLI first, let its package channels catch up, and only then
merge and deploy the higher server gate.

The production deploy refuses a gate ahead of the newest published CLI. The
branch invariant rests on step 2's `-C`, which resets a leftover `dev` onto
`origin/main` rather than resuming it, and on both repositories being set to
squash-only merges with Automatically delete head branches enabled.

## Architecture

Expand Down Expand Up @@ -117,10 +151,11 @@ fetch the fleet-reading commands share.

## Releases

The version lives in one place, `version` in `main.go`. Bump it, then tag the
commit that bumped it. Nothing injects the version at build time, because
`-ldflags -X` cannot write to a Go const; the release workflow instead refuses
to build when the tag and the const disagree.
The version lives in one place, `version` in `main.go`. Change it in a release
pull request, then tag the commit that merges it. Product pull requests leave
it alone. Nothing injects the version at build time, because `-ldflags -X`
cannot write to a Go const; the release workflow refuses to build when the tag
and the const disagree.

The workflow also refuses to build a tag that does not sit on `main`. GitHub
rulesets cannot express that, because a tag rule can restrict who creates a
Expand Down
24 changes: 10 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Do everything below once.
1. Register at [aur.archlinux.org](https://aur.archlinux.org/register), then:

```sh
cd "$(mktemp -d)"
ssh-keygen -t ed25519 -N "" -f aur_key
cat aur_key.pub # paste into SSH Public Key in your AUR account settings
cat aur_key # add as the Actions secret AUR_KEY
Expand All @@ -103,26 +104,21 @@ Do everything below once.

1. Add one ruleset (Settings > Rules > Rulesets) targeting the default branch:
require a pull request with 0 approvals, require the `build` status check,
block force pushes, restrict deletions. Add a second targeting `v*` tags:
block force pushes, restrict deletions.
allowed merge method squash only, block force pushes, restrict deletions.
Add a second targeting `v*` tags: block force pushes, restrict deletions.

## Releasing
1. Enable **Automatically delete head branches** (Settings > General).

1. Bump `version` in `main.go`, then open and merge a pull request:
## Releasing

```sh
git checkout -b version-0.1.0
git commit -am "Version 0.1.0"
git push -u origin version-0.1.0
```
1. Change `version` in `main.go`, then open and merge a release pull request.

1. Tag the new commit that merging created:
1. Tag the commit that merging created:

```sh
git checkout main && git pull
grep '^const version' main.go
git tag v0.1.0
git push origin v0.1.0
git switch main && git pull
tag="v$(sed -n 's/^const version = "\(.*\)"$/\1/p' main.go)"
git tag "$tag" && git push origin "$tag"
```

1. Write the release notes into the empty release body on GitHub, following the
Expand Down
68 changes: 68 additions & 0 deletions internal/commands/account_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package commands

import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
)

func AccountDelete(arguments []string) error {

if len(arguments) != 0 {
return errors.New("account delete takes no arguments")
}

fmt.Print("Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ")

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, "/account", 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)))
}

// The stored login died with the account, so it goes whether or not the
// file is still there
path, err := keyPath()

if err != nil {
return err
}

err = os.Remove(path)

if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}

fmt.Println("Account deleted.")

return nil
}
122 changes: 122 additions & 0 deletions internal/commands/account_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package commands

import (
"net/http"
"os"
"strings"
"testing"
)

func TestAccountDelete(t *testing.T) {
tests := []struct {
name string
arguments []string
answer string
refusal string
refusalCode int
wantDeleted bool
wantShown string
wantError string
}{
{
name: "confirmed with y",
answer: "y\n",
wantDeleted: true,
wantShown: "Account deleted",
},
{
name: "confirmed with yes",
answer: "YES\n",
wantDeleted: true,
wantShown: "Account deleted",
},
{
name: "declined by default",
answer: "\n",
wantShown: "Nothing deleted",
},
{
name: "declined with n",
answer: "n\n",
wantShown: "Nothing deleted",
},
{
name: "closed input",
wantShown: "Nothing deleted",
},
{
name: "the server refuses while a fleet is owned",
answer: "y\n",
refusal: "you still own fleets, hand each one over or delete it first",
refusalCode: http.StatusConflict,
wantDeleted: true,
wantError: "you still own fleets, hand each one over or delete it first",
},
{
name: "arguments are refused",
arguments: []string{"everything"},
wantError: "takes no arguments",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
deleted := false

mux := http.NewServeMux()

mux.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) {
deleted = true

if test.refusal != "" {
http.Error(w, test.refusal, test.refusalCode)
return
}

w.WriteHeader(http.StatusNoContent)
})

loggedInTestServer(t, mux)

path, err := keyPath()

if err != nil {
t.Fatal(err)
}

answerOnStdin(t, test.answer)

printed, err := captureStdout(t, func() error {
return AccountDelete(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)
}
} else if err != nil {
t.Fatal(err)
}

if deleted != test.wantDeleted {
t.Errorf("the server saw the account deleted = %v, want %v", deleted, test.wantDeleted)
}

if test.wantShown != "" && !strings.Contains(printed, test.wantShown) {
t.Errorf("the output %q does not show %q", printed, test.wantShown)
}

// The stored login is worthless once the account is gone, and must
// survive anything short of a completed delete
_, statErr := os.Stat(path)

switch {
case test.wantDeleted && test.wantError == "" && statErr == nil:
t.Error("the login is still stored although the account was deleted")

case (!test.wantDeleted || test.wantError != "") && statErr != nil:
t.Errorf("the login was removed although the account was not deleted: %v", statErr)
}
})
}
}
25 changes: 22 additions & 3 deletions internal/commands/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,18 @@ func loggedInTestServer(t *testing.T, handler http.Handler) {
t.Fatal(err)
}

server := httptest.NewServer(handler)
// Every command reaching a logged-in server must carry the stored key, so
// the fixture proves it once rather than each command remembering to
authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer ssk_test" {
t.Errorf("%s %s carried authorization %q, want the stored key",
r.Method, r.URL.Path, r.Header.Get("Authorization"))
}

handler.ServeHTTP(w, r)
})

server := httptest.NewServer(authorized)

t.Cleanup(server.Close)

Expand Down Expand Up @@ -219,6 +230,12 @@ func TestTakeServerFlag(t *testing.T) {
}

func TestApiRequestBase(t *testing.T) {
previousVersion := CliVersion

CliVersion = "1.2.3"

t.Cleanup(func() { CliVersion = previousVersion })

tests := []struct {
name string
chosenBase string
Expand Down Expand Up @@ -251,8 +268,10 @@ func TestApiRequestBase(t *testing.T) {
t.Errorf("url = %q, want %q", request.URL.String(), test.wantUrl)
}

if request.Header.Get("User-Agent") != "superstack/"+CliVersion {
t.Errorf("User-Agent = %q, want the CLI version", request.Header.Get("User-Agent"))
// Pinned to a literal, not to CliVersion: the server's gate parses
// this exact shape, so deriving it here would agree with any value
if request.Header.Get("User-Agent") != "superstack/1.2.3" {
t.Errorf("User-Agent = %q, want superstack/1.2.3", request.Header.Get("User-Agent"))
}
})
}
Expand Down
Loading