Skip to content

Commit 64b326c

Browse files
committed
Init
0 parents  commit 64b326c

19 files changed

Lines changed: 1195 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-go@v5
16+
with:
17+
go-version-file: go.mod
18+
19+
- run: go build ./...
20+
21+
- run: go test ./...

.github/workflows/release.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- uses: actions/setup-go@v5
20+
with:
21+
go-version-file: go.mod
22+
23+
- uses: goreleaser/goreleaser-action@v6
24+
with:
25+
version: "~> v2"
26+
args: release --clean
27+
env:
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
push
2+
dist/

.goreleaser.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
version: 2
2+
3+
project_name: push
4+
5+
before:
6+
hooks:
7+
- go mod tidy
8+
9+
builds:
10+
- main: .
11+
binary: push
12+
env:
13+
- CGO_ENABLED=0
14+
goos:
15+
- linux
16+
- darwin
17+
goarch:
18+
- amd64
19+
- arm64
20+
ldflags:
21+
- -s -w
22+
- -X github.com/techulus/push-cli/cmd.Version={{.Version}}
23+
- -X github.com/techulus/push-cli/cmd.Commit={{.Commit}}
24+
- -X github.com/techulus/push-cli/cmd.BuildDate={{.Date}}
25+
26+
archives:
27+
- format: tar.gz
28+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
29+
30+
checksum:
31+
name_template: "checksums.txt"
32+
33+
changelog:
34+
sort: asc
35+
filters:
36+
exclude:
37+
- "^docs:"
38+
- "^test:"
39+
- "^chore:"
40+
41+
brews:
42+
- repository:
43+
owner: techulus
44+
name: homebrew-tap
45+
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
46+
directory: Formula
47+
homepage: https://push.techulus.com
48+
description: CLI for sending push notifications via Push by Techulus
49+
license: MIT
50+
install: |
51+
bin.install "push"
52+
test: |
53+
system "#{bin}/push", "--version"
54+
55+
release:
56+
github:
57+
owner: techulus
58+
name: push-cli

.mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
go = "latest"

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Push CLI
2+
3+
A command-line tool for sending push notifications via [Push by Techulus](https://push.techulus.com).
4+
5+
## Install
6+
7+
### Homebrew
8+
9+
```bash
10+
brew install techulus/tap/push
11+
```
12+
13+
### From source
14+
15+
```bash
16+
go install github.com/techulus/push-cli@latest
17+
```
18+
19+
### Binary releases
20+
21+
Download pre-built binaries from [GitHub Releases](https://github.com/techulus/push-cli/releases).
22+
23+
## Setup
24+
25+
Get your API key from [Push by Techulus](https://push.techulus.com) and configure the CLI:
26+
27+
```bash
28+
push config set-key <your-api-key>
29+
```
30+
31+
Verify your configuration:
32+
33+
```bash
34+
push config show
35+
```
36+
37+
## Usage
38+
39+
### Send a notification
40+
41+
```bash
42+
push notify --title "Deploy Complete" --body "Production v2.1.0 is live"
43+
```
44+
45+
### With optional flags
46+
47+
```bash
48+
push notify \
49+
--title "Alert" \
50+
--body "CPU usage above 90%" \
51+
--sound default \
52+
--channel monitoring \
53+
--link "https://grafana.example.com/dashboard" \
54+
--image "https://example.com/chart.png" \
55+
--time-sensitive
56+
```
57+
58+
### Pipe body from stdin
59+
60+
```bash
61+
echo "Build failed on main" | push notify --title "CI Alert"
62+
```
63+
64+
```bash
65+
cat error.log | push notify --title "Error Log"
66+
```
67+
68+
```bash
69+
push notify --title "Disk Usage" --body - <<< "$(df -h /)"
70+
```
71+
72+
### Send async
73+
74+
```bash
75+
push notify-async --title "Queued" --body "This is processed asynchronously"
76+
```
77+
78+
### Send to a group
79+
80+
```bash
81+
push notify-group my-team --title "Standup" --body "Daily standup in 5 minutes"
82+
```
83+
84+
### Available sounds
85+
86+
`default`, `arcade`, `correct`, `fail`, `harp`, `reveal`, `bubble`, `doorbell`, `flute`, `money`, `scifi`, `clear`, `elevator`, `guitar`, `pop`
87+
88+
## Configuration
89+
90+
The API key is stored in `~/.push/config.yaml`.
91+
92+
## License
93+
94+
MIT

cmd/config.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/techulus/push-cli/internal/config"
10+
)
11+
12+
var configCmd = &cobra.Command{
13+
Use: "config",
14+
Short: "Manage CLI configuration",
15+
}
16+
17+
var setKeyCmd = &cobra.Command{
18+
Use: "set-key <api-key>",
19+
Short: "Save your Push API key",
20+
Args: cobra.ExactArgs(1),
21+
Run: func(cmd *cobra.Command, args []string) {
22+
key := strings.TrimSpace(args[0])
23+
if key == "" {
24+
fmt.Fprintln(os.Stderr, "API key cannot be empty")
25+
os.Exit(1)
26+
}
27+
if err := config.SetAPIKey(key); err != nil {
28+
fmt.Fprintf(os.Stderr, "Error saving API key: %v\n", err)
29+
os.Exit(1)
30+
}
31+
fmt.Println("API key saved successfully")
32+
},
33+
}
34+
35+
var showCmd = &cobra.Command{
36+
Use: "show",
37+
Short: "Display current configuration",
38+
Run: func(cmd *cobra.Command, args []string) {
39+
key := config.GetAPIKey()
40+
if key == "" {
41+
fmt.Println("No API key configured. Run: push config set-key <api-key>")
42+
return
43+
}
44+
fmt.Printf("API Key: %s\n", config.MaskedAPIKey())
45+
},
46+
}
47+
48+
func init() {
49+
configCmd.AddCommand(setKeyCmd)
50+
configCmd.AddCommand(showCmd)
51+
rootCmd.AddCommand(configCmd)
52+
}

cmd/notify.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/techulus/push-cli/internal/api"
11+
"github.com/techulus/push-cli/internal/config"
12+
)
13+
14+
var validSounds = []string{
15+
"default", "arcade", "correct", "fail", "harp", "reveal",
16+
"bubble", "doorbell", "flute", "money", "scifi", "clear",
17+
"elevator", "guitar", "pop",
18+
}
19+
20+
func readBodyFromStdinOrFlag(cmd *cobra.Command) (string, error) {
21+
body, _ := cmd.Flags().GetString("body")
22+
23+
if body == "-" || body == "" {
24+
stat, _ := os.Stdin.Stat()
25+
if (stat.Mode() & os.ModeCharDevice) == 0 {
26+
data, err := io.ReadAll(os.Stdin)
27+
if err != nil {
28+
return "", fmt.Errorf("reading stdin: %w", err)
29+
}
30+
trimmed := strings.TrimSpace(string(data))
31+
if trimmed == "" {
32+
return "", fmt.Errorf("body is required (use --body flag or pipe via stdin)")
33+
}
34+
return trimmed, nil
35+
}
36+
}
37+
38+
if body == "" || body == "-" {
39+
return "", fmt.Errorf("body is required (use --body flag or pipe via stdin)")
40+
}
41+
42+
return body, nil
43+
}
44+
45+
func buildNotifyRequest(cmd *cobra.Command) (api.NotifyRequest, error) {
46+
title, _ := cmd.Flags().GetString("title")
47+
body, err := readBodyFromStdinOrFlag(cmd)
48+
if err != nil {
49+
return api.NotifyRequest{}, err
50+
}
51+
52+
sound, _ := cmd.Flags().GetString("sound")
53+
if sound != "" {
54+
valid := false
55+
for _, s := range validSounds {
56+
if s == sound {
57+
valid = true
58+
break
59+
}
60+
}
61+
if !valid {
62+
return api.NotifyRequest{}, fmt.Errorf("invalid sound %q, valid sounds: %s", sound, strings.Join(validSounds, ", "))
63+
}
64+
}
65+
66+
channel, _ := cmd.Flags().GetString("channel")
67+
link, _ := cmd.Flags().GetString("link")
68+
image, _ := cmd.Flags().GetString("image")
69+
timeSensitive, _ := cmd.Flags().GetBool("time-sensitive")
70+
71+
return api.NotifyRequest{
72+
Title: title,
73+
Body: body,
74+
Sound: sound,
75+
Channel: channel,
76+
Link: link,
77+
Image: image,
78+
TimeSensitive: timeSensitive,
79+
}, nil
80+
}
81+
82+
func newAPIClient() *api.Client {
83+
key := config.GetAPIKey()
84+
if key == "" {
85+
fmt.Fprintln(os.Stderr, "No API key configured. Run: push config set-key <api-key>")
86+
os.Exit(1)
87+
}
88+
return api.NewClient(key)
89+
}
90+
91+
func addNotifyFlags(cmd *cobra.Command) {
92+
cmd.Flags().String("title", "", "Notification title (required)")
93+
cmd.Flags().String("body", "", "Notification body (use '-' to read from stdin)")
94+
cmd.Flags().String("sound", "", "Notification sound")
95+
cmd.Flags().String("channel", "", "Notification channel")
96+
cmd.Flags().String("link", "", "URL to open when notification is tapped")
97+
cmd.Flags().String("image", "", "Image URL for the notification")
98+
cmd.Flags().Bool("time-sensitive", false, "Mark as time-sensitive")
99+
cmd.MarkFlagRequired("title")
100+
}
101+
102+
var notifyCmd = &cobra.Command{
103+
Use: "notify",
104+
Short: "Send a push notification",
105+
Run: func(cmd *cobra.Command, args []string) {
106+
req, err := buildNotifyRequest(cmd)
107+
if err != nil {
108+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
109+
os.Exit(1)
110+
}
111+
112+
client := newAPIClient()
113+
resp, err := client.Notify(req)
114+
if err != nil {
115+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
116+
os.Exit(1)
117+
}
118+
119+
fmt.Println(resp)
120+
},
121+
}
122+
123+
func init() {
124+
addNotifyFlags(notifyCmd)
125+
rootCmd.AddCommand(notifyCmd)
126+
}

0 commit comments

Comments
 (0)