diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..77b5186 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,22 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + build: + name: Build for release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Test + run: go test -v ./... + - name: Build + run: go build -v ./... diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml new file mode 100644 index 0000000..a6781d9 --- /dev/null +++ b/.github/workflows/publish-github.yml @@ -0,0 +1,29 @@ +name: Publish + +on: + release: + types: [created] + +jobs: + goreleaser: + name: Publish with GoReleaser + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_TOKEN: ${{ secrets.GORELEASER_TOKEN }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..3d9f6b4 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,22 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Test + run: go test -v ./... + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b0cb0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +*.exe \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..59abe1c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,44 @@ +version: 2 + +builds: + - binary: apialerts + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + +archives: + - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + formats_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: "checksums.txt" + +homebrew_casks: + - repository: + owner: apialerts + name: homebrew-tap + token: "{{ .Env.GORELEASER_TOKEN }}" + homepage: "https://apialerts.com" + description: "API Alerts CLI — send events from your terminal" + +scoops: + - repository: + owner: apialerts + name: scoop-bucket + token: "{{ .Env.GORELEASER_TOKEN }}" + homepage: "https://apialerts.com" + description: "API Alerts CLI — send events from your terminal" + +changelog: + sort: asc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59c8785 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 apialerts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index db65080..54367c5 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -# apialerts-cli \ No newline at end of file +# API Alerts • CLI + +[GitHub Repo](https://github.com/apialerts/apialerts-cli) + +A command-line interface for [apialerts.com](https://apialerts.com). Send events from your terminal, scripts, and CI/CD pipelines. + +## Installation + +### Homebrew (macOS / Linux) + +```bash +brew tap apialerts/tap +brew install apialerts +``` + +### Scoop (Windows) + +```bash +scoop bucket add apialerts https://github.com/apialerts/scoop-bucket +scoop install apialerts +``` + +### Go Install + +```bash +go install github.com/apialerts/apialerts-cli@latest +``` + +### Download Binary + +Download the latest binary from the [Releases](https://github.com/apialerts/apialerts-cli/releases) page. + +## Setup + +Configure your API key once. The key is stored in `~/.apialerts/config.json`. + +```bash +apialerts config --key your_api_key +``` + +Verify your configuration + +```bash +apialerts config +``` + +## Send Events + +Send an event with a message + +```bash +apialerts send -m "Deploy completed" +``` + +Send an event with a name and title + +```bash +apialerts send -e user.purchase -t "New Sale" -m "$49.99 from john@example.com" -c payments +``` + +### Optional Properties + +You can optionally specify an event name, title, channel, tags, and a link. + +```bash +apialerts send -m "Payment failed" -c payments -g billing,error -l https://dashboard.example.com +``` + +| Flag | Short | Description | +|------|-------|-------------| +| `--message` | `-m` | Event message (required) | +| `--event` | `-e` | Event name for routing (optional, e.g. `user.purchase`) | +| `--title` | `-t` | Event title (optional) | +| `--channel` | `-c` | Target channel (optional, uses default channel if not set) | +| `--tags` | `-g` | Comma-separated tags (optional) | +| `--link` | `-l` | Associated URL (optional) | +| `--key` | | API key override (optional, uses stored config if not set) | + +### Override API Key + +You can override the stored API key for a single request. + +```bash +apialerts send -m "Hello World" --key other_api_key +``` + +## Test Connectivity + +Send a test event to verify your API key and connection. + +```bash +apialerts test +``` + +## CI/CD Examples + +### GitHub Actions + +```yaml +- name: Send deploy alert + run: | + apialerts send -m "Deployed ${{ github.sha }}" -c deployments -g ci,deploy --key ${{ secrets.APIALERTS_API_KEY }} +``` + +### Shell Script + +```bash +#!/bin/bash +apialerts send -m "Backup completed" -c ops -g backup,cron +``` diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..0ca7507 --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,7 @@ +# Release Process + +1. Update the version in `cmd/constants.go` +2. PR to `main` branch and merge if tests pass +3. Ensure GitHub Actions tests pass on `main` before creating a release +4. Create a new release on GitHub on the `main` branch with a `v` prefixed tag (e.g. `v0.1.0`) +5. GoReleaser will automatically build cross-platform binaries and attach them to the release diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..f6db090 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/apialerts/apialerts-cli/internal/config" + "github.com/spf13/cobra" +) + +var configKey string + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configure the CLI", + Long: "Set your API key for authentication. The key is stored in ~/.apialerts/config.json.", + RunE: func(cmd *cobra.Command, args []string) error { + if configKey == "" { + // Show current config + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + if cfg.APIKey == "" { + fmt.Println("No API key configured.") + fmt.Println("Run: apialerts config --key ") + } else { + masked := cfg.APIKey[:6] + "..." + cfg.APIKey[len(cfg.APIKey)-4:] + fmt.Printf("API Key: %s\n", masked) + } + return nil + } + + cfg := &config.CLIConfig{APIKey: configKey} + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Println("API key saved.") + return nil + }, +} + +func init() { + configCmd.Flags().StringVar(&configKey, "key", "", "Your API Alerts API key") + rootCmd.AddCommand(configCmd) +} diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..dc7d6b5 --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,6 @@ +package cmd + +const ( + IntegrationName = "cli" + Version = "0.0.1" +) diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..676d20c --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "apialerts", + Short: "API Alerts CLI — send events from your terminal", + Long: "A command-line interface for apialerts.com. Configure your API key, send events, and test connectivity.", + Version: Version, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/send.go b/cmd/send.go new file mode 100644 index 0000000..cde7d9f --- /dev/null +++ b/cmd/send.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/apialerts/apialerts-cli/internal/config" + "github.com/apialerts/apialerts-go" + "github.com/spf13/cobra" +) + +var ( + sendEvent string + sendTitle string + sendMessage string + sendChannel string + sendTags string + sendLink string + sendKey string +) + +var sendCmd = &cobra.Command{ + Use: "send", + Short: "Send an event", + Long: "Send an event to API Alerts. Requires a message at minimum.", + Example: ` apialerts send -m "Deploy completed" + apialerts send -e user.purchase -t "New Sale" -m "$49.99 from john@example.com" + apialerts send -m "Payment failed" -c payments -g billing,error + apialerts send -m "Build passed" -l https://ci.example.com/build/123`, + RunE: func(cmd *cobra.Command, args []string) error { + if sendMessage == "" { + return fmt.Errorf("message is required — use -m \"your message\"") + } + + // Resolve API key: flag > config file + apiKey := sendKey + if apiKey == "" { + key, err := config.GetAPIKey() + if err != nil { + return err + } + apiKey = key + } + + // Parse tags + var tags []string + if sendTags != "" { + for _, t := range strings.Split(sendTags, ",") { + trimmed := strings.TrimSpace(t) + if trimmed != "" { + tags = append(tags, trimmed) + } + } + } + + // Configure and send + apialerts.Configure(apiKey) + apialerts.SetIntegration(IntegrationName) + + event := apialerts.Event{ + Event: sendEvent, + Title: sendTitle, + Message: sendMessage, + Channel: sendChannel, + Tags: tags, + Link: sendLink, + } + + if err := apialerts.SendAsync(event); err != nil { + return fmt.Errorf("failed to send: %w", err) + } + + fmt.Println("Event sent.") + return nil + }, +} + +func init() { + sendCmd.Flags().StringVarP(&sendEvent, "event", "e", "", "Event name (e.g. user.purchase)") + sendCmd.Flags().StringVarP(&sendTitle, "title", "t", "", "Event title") + sendCmd.Flags().StringVarP(&sendMessage, "message", "m", "", "Event message (required)") + sendCmd.Flags().StringVarP(&sendChannel, "channel", "c", "", "Target channel") + sendCmd.Flags().StringVarP(&sendTags, "tags", "g", "", "Comma-separated tags") + sendCmd.Flags().StringVarP(&sendLink, "link", "l", "", "Associated URL") + sendCmd.Flags().StringVar(&sendKey, "key", "", "API key override (instead of stored config)") + rootCmd.AddCommand(sendCmd) +} diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..08a2482 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/apialerts/apialerts-cli/internal/config" + "github.com/apialerts/apialerts-go" + "github.com/spf13/cobra" +) + +var testCmd = &cobra.Command{ + Use: "test", + Short: "Send a test event", + Long: "Send a test event to verify your API key and connectivity.", + RunE: func(cmd *cobra.Command, args []string) error { + apiKey, err := config.GetAPIKey() + if err != nil { + return err + } + + apialerts.Configure(apiKey) + apialerts.SetIntegration(IntegrationName) + + event := apialerts.Event{ + Event: "cli.test", + Title: "CLI Test Event", + Message: "Test event from API Alerts CLI", + Tags: []string{"test", "cli"}, + } + + fmt.Println("Sending test event...") + if err := apialerts.SendAsync(event); err != nil { + return fmt.Errorf("test failed: %w", err) + } + + fmt.Println("Test event sent successfully.") + return nil + }, +} + +func init() { + rootCmd.AddCommand(testCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cfd868 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/apialerts/apialerts-cli + +go 1.22 + +require ( + github.com/apialerts/apialerts-go v1.2.0-alpha.1 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..52b09dc --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/apialerts/apialerts-go v1.2.0-alpha.1 h1:NqJ4Zhl2GW6yrvjAsVxGBBvBRONiBkOC/5/FEIj6EYs= +github.com/apialerts/apialerts-go v1.2.0-alpha.1/go.mod h1:8axzOXPrs/4LAEZPv4qIw9+8ccOx84h7YAqITTsZwEM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8fcc947 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +const configDir = ".apialerts" +const configFile = "config.json" + +// configDirOverride allows overriding the config directory for testing. +var configDirOverride string + +type CLIConfig struct { + APIKey string `json:"api_key"` +} + +func configPath() (string, error) { + if configDirOverride != "" { + return filepath.Join(configDirOverride, configFile), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, configDir, configFile), nil +} + +func Load() (*CLIConfig, error) { + path, err := configPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &CLIConfig{}, nil + } + return nil, err + } + + var cfg CLIConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func Save(cfg *CLIConfig) error { + path, err := configPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func GetAPIKey() (string, error) { + cfg, err := Load() + if err != nil { + return "", err + } + if cfg.APIKey == "" { + return "", errors.New("no API key configured — run: apialerts config --key ") + } + return cfg.APIKey, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..67b82bc --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func setupTestDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + configDirOverride = dir + t.Cleanup(func() { configDirOverride = "" }) + return dir +} + +func TestLoadNoFile(t *testing.T) { + setupTestDir(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cfg.APIKey != "" { + t.Fatalf("expected empty API key, got %q", cfg.APIKey) + } +} + +func TestSaveAndLoad(t *testing.T) { + setupTestDir(t) + + err := Save(&CLIConfig{APIKey: "test-key-123"}) + if err != nil { + t.Fatalf("expected no error saving, got %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error loading, got %v", err) + } + if cfg.APIKey != "test-key-123" { + t.Fatalf("expected API key %q, got %q", "test-key-123", cfg.APIKey) + } +} + +func TestSaveFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permissions not supported on Windows") + } + dir := setupTestDir(t) + + err := Save(&CLIConfig{APIKey: "test-key"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + info, err := os.Stat(filepath.Join(dir, configFile)) + if err != nil { + t.Fatalf("expected config file to exist, got %v", err) + } + + perm := info.Mode().Perm() + if perm != 0600 { + t.Fatalf("expected file permissions 0600, got %04o", perm) + } +} + +func TestLoadCorruptedFile(t *testing.T) { + dir := setupTestDir(t) + + err := os.WriteFile(filepath.Join(dir, configFile), []byte("not json"), 0600) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + _, err = Load() + if err == nil { + t.Fatal("expected error for corrupted config, got nil") + } +} + +func TestGetAPIKeyNotConfigured(t *testing.T) { + setupTestDir(t) + + _, err := GetAPIKey() + if err == nil { + t.Fatal("expected error when no key configured, got nil") + } +} + +func TestGetAPIKeyConfigured(t *testing.T) { + setupTestDir(t) + + err := Save(&CLIConfig{APIKey: "my-key"}) + if err != nil { + t.Fatalf("expected no error saving, got %v", err) + } + + key, err := GetAPIKey() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if key != "my-key" { + t.Fatalf("expected key %q, got %q", "my-key", key) + } +} + +func TestSaveOverwrite(t *testing.T) { + setupTestDir(t) + + Save(&CLIConfig{APIKey: "old-key"}) + Save(&CLIConfig{APIKey: "new-key"}) + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cfg.APIKey != "new-key" { + t.Fatalf("expected key %q, got %q", "new-key", cfg.APIKey) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a515c0c --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/apialerts/apialerts-cli/cmd" + +func main() { + cmd.Execute() +}