Skip to content

Commit 4a415e0

Browse files
authored
feat(cli): add gander auth — install a new API token (#51) (#52)
Adds a new subcommand for installing a rotated or re-issued API token without redoing the full signup flow. The new token is validated against GET /api/me before overwriting ~/.gander; email, api_url, and the local shares map are preserved. Help text, shell completions, the man page, and README subcommand list are updated to advertise the command only when the user is already signed up. Tests cover the happy path (validation + persist + preservation), the invalid-token path (no config rewrite on 401), the not-signed-up guard, and bad usage (zero/extra args).
1 parent 62a6f54 commit 4a415e0

9 files changed

Lines changed: 198 additions & 5 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ gander list # table of active shares
133133
gander remove README.md # 404s the short link
134134
gander remove --all # remove every share in your account
135135
gander manage # opens the dashboard in your browser
136+
gander auth <api_token> # install a rotated/issued API token
136137
```
137138

138139
These commands appear in `gander --help` only after a successful signup,
@@ -141,7 +142,10 @@ since they require an API token stored in `~/.gander` (`api_token`,
141142
The CLI ships with `https://gander.md` as the default endpoint; set
142143
`api_url` in your config to point at a self-hosted instance.
143144

144-
API token rotation is supported via the dashboard (`gander manage` → rotate).
145+
API tokens can be rotated from the dashboard (`gander manage` → rotate).
146+
After rotating, install the new token on each machine with
147+
`gander auth <token>`; the CLI validates it against `/api/me` before
148+
overwriting `~/.gander`.
145149

146150
### Configuration (`~/.gander`)
147151

@@ -192,6 +196,7 @@ gander share [--watch] <file> Upload to gander.md and open the share link
192196
gander remove [--all] [<file>] Delete a share from gander.md
193197
gander list List shares currently on gander.md
194198
gander manage Open the dashboard in your browser
199+
gander auth <api_token> Install a new API token (e.g. after rotating)
195200
gander completion {bash|zsh} Print a shell completion script
196201
```
197202

api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ func (c *apiClient) DeleteShare(uuid string) error {
140140
return c.do("DELETE", fmt.Sprintf("/api/shares/%s", uuid), nil, nil)
141141
}
142142

143+
func (c *apiClient) ValidateToken() error {
144+
return c.do("GET", "/api/me", nil, nil)
145+
}
146+
143147
func (c *apiClient) ListShares() ([]shareResp, error) {
144148
var out []shareResp
145149
if err := c.do("GET", "/api/shares", nil, &out); err != nil {

auth.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import "fmt"
4+
5+
func runAuth(args []string) error {
6+
if len(args) != 1 || args[0] == "" {
7+
return fmt.Errorf("usage: gander auth <api_token>")
8+
}
9+
token := args[0]
10+
11+
cfg, err := requireAuth()
12+
if err != nil {
13+
return err
14+
}
15+
16+
cli := newAPIClient(cfg.APIURL, token)
17+
if err := cli.ValidateToken(); err != nil {
18+
return fmt.Errorf("auth: %w", err)
19+
}
20+
21+
cfg.APIToken = token
22+
if err := WriteConfig(cfg); err != nil {
23+
return fmt.Errorf("save config: %w", err)
24+
}
25+
26+
fmt.Printf("API token saved to ~/.gander (chmod 600).\n")
27+
return nil
28+
}

auth_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestRunAuthRequiresAuth(t *testing.T) {
13+
tmp := t.TempDir()
14+
t.Setenv("HOME", tmp)
15+
16+
if err := os.WriteFile(filepath.Join(tmp, ".gander"), []byte(`{"api_url": "https://gander.md"}`), 0600); err != nil {
17+
t.Fatal(err)
18+
}
19+
20+
err := runAuth([]string{"gmd_new"})
21+
if err == nil {
22+
t.Fatal("expected auth error")
23+
}
24+
if !strings.Contains(err.Error(), "not signed up") {
25+
t.Errorf("err = %v", err)
26+
}
27+
}
28+
29+
func TestRunAuthRejectsBadUsage(t *testing.T) {
30+
tmp := t.TempDir()
31+
t.Setenv("HOME", tmp)
32+
33+
if err := os.WriteFile(filepath.Join(tmp, ".gander"), []byte(`{"api_url":"https://gander.md","api_token":"gmd_existing"}`), 0600); err != nil {
34+
t.Fatal(err)
35+
}
36+
37+
for _, args := range [][]string{nil, {}, {"a", "b"}} {
38+
err := runAuth(args)
39+
if err == nil {
40+
t.Fatalf("expected error for args=%v", args)
41+
}
42+
if !strings.Contains(err.Error(), "usage") {
43+
t.Errorf("args=%v err=%v", args, err)
44+
}
45+
}
46+
}
47+
48+
func TestRunAuthValidatesAndPersists(t *testing.T) {
49+
tmp := t.TempDir()
50+
t.Setenv("HOME", tmp)
51+
52+
var (
53+
gotPath string
54+
gotBearer string
55+
)
56+
mux := http.NewServeMux()
57+
mux.HandleFunc("/api/me", func(w http.ResponseWriter, r *http.Request) {
58+
gotPath = r.URL.Path
59+
gotBearer = r.Header.Get("Authorization")
60+
w.WriteHeader(200)
61+
})
62+
srv := httptest.NewServer(mux)
63+
defer srv.Close()
64+
65+
original := `{"api_url":"` + srv.URL + `","email":"alice@example.com","api_token":"gmd_old","shares":{}}`
66+
if err := os.WriteFile(filepath.Join(tmp, ".gander"), []byte(original), 0600); err != nil {
67+
t.Fatal(err)
68+
}
69+
70+
if err := runAuth([]string{"gmd_new"}); err != nil {
71+
t.Fatalf("auth: %v", err)
72+
}
73+
if gotPath != "/api/me" {
74+
t.Errorf("path = %q, want /api/me", gotPath)
75+
}
76+
if gotBearer != "Bearer gmd_new" {
77+
t.Errorf("bearer = %q, want Bearer gmd_new", gotBearer)
78+
}
79+
80+
got, err := LoadConfig()
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
if got.APIToken != "gmd_new" {
85+
t.Errorf("APIToken = %q, want gmd_new", got.APIToken)
86+
}
87+
if got.Email != "alice@example.com" {
88+
t.Errorf("Email = %q, want alice@example.com (preserved)", got.Email)
89+
}
90+
if got.APIURL != srv.URL {
91+
t.Errorf("APIURL = %q, want %q (preserved)", got.APIURL, srv.URL)
92+
}
93+
}
94+
95+
func TestRunAuthRejectsInvalidToken(t *testing.T) {
96+
tmp := t.TempDir()
97+
t.Setenv("HOME", tmp)
98+
99+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+
http.Error(w, "unauthorized", http.StatusUnauthorized)
101+
}))
102+
defer srv.Close()
103+
104+
original := []byte(`{"api_url":"` + srv.URL + `","email":"alice@example.com","api_token":"gmd_old"}`)
105+
if err := os.WriteFile(filepath.Join(tmp, ".gander"), original, 0600); err != nil {
106+
t.Fatal(err)
107+
}
108+
109+
err := runAuth([]string{"gmd_bad"})
110+
if err == nil {
111+
t.Fatal("expected error for 401")
112+
}
113+
if !strings.Contains(err.Error(), "auth") {
114+
t.Errorf("err = %v", err)
115+
}
116+
117+
got, statErr := os.ReadFile(filepath.Join(tmp, ".gander"))
118+
if statErr != nil {
119+
t.Fatal(statErr)
120+
}
121+
if string(got) != string(original) {
122+
t.Errorf("~/.gander was modified after a failed auth:\nbefore: %s\nafter: %s", original, got)
123+
}
124+
}

completions/_gander

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ _gander() {
1111
'remove:Delete a share from gander.md'
1212
'list:List shares currently on gander.md'
1313
'manage:Open the dashboard in your browser'
14+
'auth:Install a new API token (validate against /api/me first)'
1415
'completion:Print a shell completion script'
1516
'--upgrade:Download and install the latest release'
1617
'upgrade:Alias for --upgrade'
@@ -46,6 +47,10 @@ _gander() {
4647
manage)
4748
_arguments
4849
;;
50+
auth)
51+
_arguments \
52+
'1:api_token:'
53+
;;
4954
completion)
5055
_arguments \
5156
'1:shell:(bash zsh)'

completions/gander.bash

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ _gander_completions() {
88
prev="${COMP_WORDS[COMP_CWORD-1]}"
99

1010
if [[ ${COMP_CWORD} -eq 1 ]]; then
11-
cmds="signup share remove list manage completion --upgrade upgrade --help -h -help help"
11+
cmds="signup share remove list manage auth completion --upgrade upgrade --help -h -help help"
1212
COMPREPLY=( $(compgen -W "${cmds}" -- "${cur}") )
1313
return 0
1414
fi
@@ -30,6 +30,10 @@ _gander_completions() {
3030
COMPREPLY=( $(compgen -W "bash zsh" -- "${cur}") )
3131
return 0
3232
;;
33+
auth)
34+
COMPREPLY=()
35+
return 0
36+
;;
3337
-upgrade|--upgrade|upgrade)
3438
COMPREPLY=()
3539
return 0

main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ func main() {
5656
os.Exit(1)
5757
}
5858
return
59+
case "auth":
60+
if err := runAuth(os.Args[2:]); err != nil {
61+
fmt.Fprintf(os.Stderr, "auth: %v\n", err)
62+
os.Exit(1)
63+
}
64+
return
5965
case "--help", "-h", "help":
6066
printUsage(os.Stdout)
6167
return
@@ -181,6 +187,7 @@ func printUsage(w io.Writer) {
181187
fmt.Fprintln(w, " Delete a share from gander.md")
182188
fmt.Fprintln(w, " gander list List shares currently on gander.md")
183189
fmt.Fprintln(w, " gander manage Open the dashboard in your browser")
190+
fmt.Fprintln(w, " gander auth <api_token> Install a new API token (e.g. after rotating in the dashboard)")
184191
}
185192
fmt.Fprintln(w, " gander --upgrade Download and install the latest release")
186193
fmt.Fprintln(w, " gander completion {bash|zsh} Print a shell completion script to stdout")
@@ -190,7 +197,7 @@ func printUsage(w io.Writer) {
190197
fmt.Fprintln(w, " -watch Live-reload the local browser preview on save")
191198
if !authed {
192199
fmt.Fprintln(w)
193-
fmt.Fprintln(w, "Run `gander signup --email you@example.com` to enable share / remove / list / manage.")
200+
fmt.Fprintln(w, "Run `gander signup --email you@example.com` to enable share / remove / list / manage / auth.")
194201
}
195202
}
196203

man/man1/gander.1

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ gander \- render Markdown locally, optionally share it on gander.md
2323
.sp
2424
.B gander manage
2525
.sp
26+
.B gander auth
27+
.IR api_token
28+
.sp
2629
.B gander completion
2730
.RB { bash | zsh }
2831
.sp
@@ -40,8 +43,9 @@ the
4043
.BR share ","
4144
.BR remove ","
4245
.B list ","
46+
.B manage ,
4347
and
44-
.B manage
48+
.B auth
4549
subcommands upload Markdown to
4650
.RI \(la https://gander.md \(ra
4751
("gander.md") and return short share links.
@@ -134,6 +138,18 @@ file, watch flag, last update, URL).
134138
Open the gander.md dashboard in your browser. Requires an existing API
135139
token (sign up first). The dashboard shows your shares, rotates your API
136140
token, and lets you attach a password to log in via the web.
141+
.SS "Auth"
142+
.TP
143+
.B auth
144+
Validate and install a new API token, e.g. one you rotated in the
145+
dashboard. Requires an existing API token (sign up first); the command
146+
checks the new token against
147+
.B /api/me
148+
before overwriting
149+
.IR ~/.gander .
150+
Email and
151+
.B api_url
152+
are preserved.
137153
.SS "Completion"
138154
.TP
139155
.B completion

manpage_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func TestManPageExistsAndRenders(t *testing.T) {
3333
"NAME", "SYNOPSIS", "DESCRIPTION", "OPTIONS", "COMMANDS", "FILES",
3434
"EXIT STATUS", "EXAMPLES",
3535
"gander signup", "gander share", "gander remove",
36-
"gander list", "gander completion",
36+
"gander list", "gander auth", "gander completion",
3737
"--upgrade",
3838
} {
3939
if !strings.Contains(text, want) {

0 commit comments

Comments
 (0)