Skip to content

Commit 6353254

Browse files
joemasilotticlaude
andcommitted
Accept a token instead of a full URL
The CLI now stores just the 32-character token from a Custom source on pingrb.com and constructs the webhook URL itself when sending. URLs (or anything containing a slash/whitespace) are rejected with a helpful error. Existing v0.1.0 users will see a migration message on next ping prompting them to re-run `pingrb config <token>`. A new PINGRB_HOST env var overrides the base URL (defaults to https://pingrb.com), useful for tests and any future non-production instance. This is a breaking change in the config file format, so the next tagged release should be v0.2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4a17925 commit 6353254

3 files changed

Lines changed: 99 additions & 24 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,21 @@ go install github.com/ruby-native/pingrb-cli@latest
1717
## Usage
1818

1919
```sh
20-
pingrb config https://pingrb.com/webhooks/custom/<your-token>
20+
pingrb config <your-token>
2121
pingrb "deploy failed"
2222
pingrb "job done" --body "backfill finished" --url https://example.com/jobs/42
2323
some-long-job && pingrb "$?" --body "done"
2424
```
2525

26-
The webhook URL is the Custom source URL from your account at https://pingrb.com.
26+
Get the token from your Custom source on https://pingrb.com (it's the last
27+
segment of the webhook URL).
2728

2829
Config is stored at the platform's standard user config dir
2930
(`~/.config/pingrb` on Linux, `~/Library/Application Support/pingrb` on macOS).
3031

32+
Set `PINGRB_HOST` to point at a non-production instance (defaults to
33+
`https://pingrb.com`).
34+
3135
## Develop
3236

3337
```sh

main.go

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@ import (
1515

1616
var version = "dev"
1717

18+
const defaultHost = "https://pingrb.com"
19+
1820
const usage = `pingrb sends a push notification to your phone.
1921
2022
Usage:
21-
pingrb config <url> set the webhook URL from your pingrb Custom source
22-
pingrb config print the configured URL
23+
pingrb config <token> save your Custom source token from pingrb.com
24+
pingrb config print the saved token
2325
pingrb <title> [--body BODY] [--url URL]
24-
send a push
26+
send a push
2527
2628
Examples:
27-
pingrb config https://pingrb.com/webhooks/custom/abc123
29+
pingrb config abc123def456...
2830
pingrb "deploy failed"
2931
pingrb "job done" --body "backfill finished" --url https://example.com/jobs/42
3032
`
@@ -56,15 +58,15 @@ func run(args []string, stdout io.Writer) error {
5658

5759
func runConfig(args []string, stdout io.Writer) error {
5860
if len(args) == 0 {
59-
url, err := readConfig()
61+
token, err := readConfig()
6062
if err != nil {
6163
return err
6264
}
63-
fmt.Fprintln(stdout, url)
65+
fmt.Fprintln(stdout, token)
6466
return nil
6567
}
6668
if len(args) > 1 {
67-
return errors.New("config takes at most one URL argument")
69+
return errors.New("config takes at most one token argument")
6870
}
6971
if err := writeConfig(args[0]); err != nil {
7072
return err
@@ -87,11 +89,11 @@ func runPing(args []string) error {
8789
return err
8890
}
8991

90-
endpoint, err := readConfig()
92+
token, err := readConfig()
9193
if err != nil {
9294
return err
9395
}
94-
return sendPing(endpoint, title, *body, *url)
96+
return sendPing(token, title, *body, *url)
9597
}
9698

9799
func configPath() (string, error) {
@@ -110,26 +112,44 @@ func readConfig() (string, error) {
110112
data, err := os.ReadFile(path)
111113
if err != nil {
112114
if errors.Is(err, os.ErrNotExist) {
113-
return "", errors.New("not configured. Run `pingrb config <url>`.")
115+
return "", errors.New("not configured. Run `pingrb config <token>`.")
114116
}
115117
return "", err
116118
}
117-
url := strings.TrimSpace(string(data))
118-
if url == "" {
119-
return "", errors.New("config is empty. Run `pingrb config <url>`.")
119+
token := strings.TrimSpace(string(data))
120+
if token == "" {
121+
return "", errors.New("config is empty. Run `pingrb config <token>`.")
122+
}
123+
if strings.ContainsAny(token, "/ \t") {
124+
return "", errors.New("config looks like a URL (pre-0.2.0 format). Re-run `pingrb config <token>` with just the token.")
120125
}
121-
return url, nil
126+
return token, nil
122127
}
123128

124-
func writeConfig(url string) error {
129+
func writeConfig(input string) error {
130+
token := strings.TrimSpace(input)
131+
if token == "" {
132+
return errors.New("token cannot be empty")
133+
}
134+
if strings.ContainsAny(token, "/ \t") {
135+
return errors.New("expected a token, not a URL or path. Copy the 32-character token from your Custom source on pingrb.com.")
136+
}
137+
125138
path, err := configPath()
126139
if err != nil {
127140
return err
128141
}
129142
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
130143
return err
131144
}
132-
return os.WriteFile(path, []byte(url+"\n"), 0o600)
145+
return os.WriteFile(path, []byte(token+"\n"), 0o600)
146+
}
147+
148+
func host() string {
149+
if v := strings.TrimSpace(os.Getenv("PINGRB_HOST")); v != "" {
150+
return strings.TrimRight(v, "/")
151+
}
152+
return defaultHost
133153
}
134154

135155
type pingPayload struct {
@@ -138,11 +158,12 @@ type pingPayload struct {
138158
URL string `json:"url,omitempty"`
139159
}
140160

141-
func sendPing(endpoint, title, body, url string) error {
161+
func sendPing(token, title, body, url string) error {
142162
data, err := json.Marshal(pingPayload{Title: title, Body: body, URL: url})
143163
if err != nil {
144164
return err
145165
}
166+
endpoint := host() + "/webhooks/custom/" + token
146167
resp, err := http.Post(endpoint, "application/json", bytes.NewReader(data))
147168
if err != nil {
148169
return err

main_test.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"os"
10+
"path/filepath"
911
"strings"
1012
"testing"
1113
)
@@ -17,22 +19,62 @@ func isolateConfig(t *testing.T) {
1719
t.Setenv("XDG_CONFIG_HOME", dir)
1820
}
1921

22+
const testToken = "abc123testtoken"
23+
2024
func TestConfigRoundTrip(t *testing.T) {
2125
isolateConfig(t)
2226

23-
if err := run([]string{"config", "https://example.com/webhook"}, io.Discard); err != nil {
27+
if err := run([]string{"config", testToken}, io.Discard); err != nil {
2428
t.Fatal(err)
2529
}
2630

2731
var out bytes.Buffer
2832
if err := run([]string{"config"}, &out); err != nil {
2933
t.Fatal(err)
3034
}
31-
if got := strings.TrimSpace(out.String()); got != "https://example.com/webhook" {
35+
if got := strings.TrimSpace(out.String()); got != testToken {
3236
t.Errorf("got %q", got)
3337
}
3438
}
3539

40+
func TestConfigRejectsURL(t *testing.T) {
41+
isolateConfig(t)
42+
43+
err := run([]string{"config", "https://pingrb.com/webhooks/custom/abc123"}, io.Discard)
44+
if err == nil || !strings.Contains(err.Error(), "expected a token") {
45+
t.Errorf("got %v", err)
46+
}
47+
}
48+
49+
func TestConfigRejectsEmpty(t *testing.T) {
50+
isolateConfig(t)
51+
52+
err := run([]string{"config", " "}, io.Discard)
53+
if err == nil || !strings.Contains(err.Error(), "empty") {
54+
t.Errorf("got %v", err)
55+
}
56+
}
57+
58+
func TestReadRejectsLegacyURLConfig(t *testing.T) {
59+
isolateConfig(t)
60+
61+
path, err := configPath()
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
66+
t.Fatal(err)
67+
}
68+
if err := os.WriteFile(path, []byte("https://pingrb.com/webhooks/custom/abc\n"), 0o600); err != nil {
69+
t.Fatal(err)
70+
}
71+
72+
err = run([]string{"deploy failed"}, io.Discard)
73+
if err == nil || !strings.Contains(err.Error(), "pre-0.2.0") {
74+
t.Errorf("got %v", err)
75+
}
76+
}
77+
3678
func TestPingNotConfigured(t *testing.T) {
3779
isolateConfig(t)
3880

@@ -46,7 +88,9 @@ func TestPingPostsJSON(t *testing.T) {
4688
isolateConfig(t)
4789

4890
var got pingPayload
91+
var gotPath string
4992
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
gotPath = r.URL.Path
5094
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
5195
t.Errorf("Content-Type %q", ct)
5296
}
@@ -56,8 +100,9 @@ func TestPingPostsJSON(t *testing.T) {
56100
w.WriteHeader(http.StatusAccepted)
57101
}))
58102
defer srv.Close()
103+
t.Setenv("PINGRB_HOST", srv.URL)
59104

60-
if err := run([]string{"config", srv.URL}, io.Discard); err != nil {
105+
if err := run([]string{"config", testToken}, io.Discard); err != nil {
61106
t.Fatal(err)
62107
}
63108
if err := run([]string{"job done", "--body", "backfill finished", "--url", "https://example.com/jobs/42"}, io.Discard); err != nil {
@@ -66,6 +111,9 @@ func TestPingPostsJSON(t *testing.T) {
66111
if got.Title != "job done" || got.Body != "backfill finished" || got.URL != "https://example.com/jobs/42" {
67112
t.Errorf("got %+v", got)
68113
}
114+
if want := "/webhooks/custom/" + testToken; gotPath != want {
115+
t.Errorf("path = %q, want %q", gotPath, want)
116+
}
69117
}
70118

71119
func TestPingOmitsEmptyFields(t *testing.T) {
@@ -77,8 +125,9 @@ func TestPingOmitsEmptyFields(t *testing.T) {
77125
w.WriteHeader(http.StatusAccepted)
78126
}))
79127
defer srv.Close()
128+
t.Setenv("PINGRB_HOST", srv.URL)
80129

81-
if err := run([]string{"config", srv.URL}, io.Discard); err != nil {
130+
if err := run([]string{"config", testToken}, io.Discard); err != nil {
82131
t.Fatal(err)
83132
}
84133
if err := run([]string{"deploy failed"}, io.Discard); err != nil {
@@ -100,8 +149,9 @@ func TestPingErrorsOnNon2xx(t *testing.T) {
100149
_, _ = io.WriteString(w, "source not found")
101150
}))
102151
defer srv.Close()
152+
t.Setenv("PINGRB_HOST", srv.URL)
103153

104-
if err := run([]string{"config", srv.URL}, io.Discard); err != nil {
154+
if err := run([]string{"config", testToken}, io.Discard); err != nil {
105155
t.Fatal(err)
106156
}
107157
err := run([]string{"deploy failed"}, io.Discard)

0 commit comments

Comments
 (0)