From 8179f9e43fbda9fdff8c35145ab83c7fa2432670 Mon Sep 17 00:00:00 2001 From: Jared Hall Date: Wed, 18 Mar 2026 03:21:55 +1100 Subject: [PATCH 1/2] use the go sdk sendasync result --- cmd/config.go | 20 +++++++++++++++++-- cmd/init.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 6 +++++- cmd/send.go | 25 +++++++++++++++++++----- cmd/test.go | 8 +++++++- go.mod | 6 ++++-- go.sum | 9 +++++++-- 7 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 cmd/init.go diff --git a/cmd/config.go b/cmd/config.go index 4b0a03f..54da905 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -9,6 +9,22 @@ import ( var configKey string +func maskAPIKey(key string) string { + n := len(key) + + switch { + case key == "": + return "No API key configured." + case n <= 3: + // Too short, just mask everything + return "***" + case n <= 10: + return key[:1] + "..." + key[n-1:] + default: + return key[:6] + "..." + key[n-4:] + } +} + var configCmd = &cobra.Command{ Use: "config", Short: "Configure the CLI", @@ -22,9 +38,9 @@ var configCmd = &cobra.Command{ } if cfg.APIKey == "" { fmt.Println("No API key configured.") - fmt.Println("Run: apialerts config --key ") + fmt.Println("Run: apialerts init") } else { - masked := cfg.APIKey[:6] + "..." + cfg.APIKey[len(cfg.APIKey)-4:] + masked := maskAPIKey(cfg.APIKey) fmt.Printf("API Key: %s\n", masked) } return nil diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..f122d34 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/apialerts/cli/internal/config" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var initKey string + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Set up your API key", + Long: "Set your API key interactively or via flag. The key is stored in ~/.apialerts/config.json.", + Example: ` apialerts init + apialerts init --key "your-api-key"`, + RunE: func(cmd *cobra.Command, args []string) error { + key := initKey + + if key == "" { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("no terminal detected — use: apialerts init --key \"your-api-key\"") + } + fmt.Print("Enter your API key: ") + keyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + key = strings.TrimSpace(string(keyBytes)) + } + + if key == "" { + return fmt.Errorf("API key cannot be empty") + } + + cfg := &config.CLIConfig{APIKey: key} + 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() { + initCmd.Flags().StringVar(&initKey, "key", "", "Your API Alerts API key") + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 676d20c..1a064cd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,11 @@ import ( 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.", + Long: `A command-line interface for apialerts.com. Send events from your terminal, scripts, and CI/CD pipelines. + +Get started: + apialerts init + apialerts send -m "Hello from the terminal"`, Version: Version, } diff --git a/cmd/send.go b/cmd/send.go index ab032db..acd6566 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -22,11 +22,20 @@ var ( var sendCmd = &cobra.Command{ Use: "send", Short: "Send an event", - Long: "Send an event to API Alerts. Requires a message at minimum.", + Long: `Send an event to API Alerts. Requires a message at minimum. + +Properties: + -m message The notification message (required) + -e event Event name for filtering/routing (e.g. user.purchase, deploy.success) + -t title Short title displayed above the message + -c channel Target channel (uses your default channel if not set) + -g tags Comma-separated tags for filtering (e.g. billing,error) + -l link URL attached to the notification + key API key override (uses stored config if not set)`, 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`, + apialerts send -e "user.purchase" -t "New Sale" -m "$49.99 from john@example.com" -c "payments" + 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\"") @@ -66,10 +75,16 @@ var sendCmd = &cobra.Command{ Link: sendLink, } - if err := apialerts.SendAsync(event); err != nil { + result, err := apialerts.SendAsync(event) + if err != nil { return fmt.Errorf("failed to send: %w", err) } + fmt.Printf("✓ Alert sent to %s (%s)\n", result.Workspace, result.Channel) + for _, w := range result.Warnings { + fmt.Printf("! Warning: %s\n", w) + } + return nil }, } diff --git a/cmd/test.go b/cmd/test.go index 6e1acdb..1f1a2de 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -28,10 +28,16 @@ var testCmd = &cobra.Command{ Tags: []string{"test", "cli"}, } - if err := apialerts.SendAsync(event); err != nil { + result, err := apialerts.SendAsync(event) + if err != nil { return fmt.Errorf("test failed: %w", err) } + fmt.Printf("✓ Test event sent to %s (%s)\n", result.Workspace, result.Channel) + for _, w := range result.Warnings { + fmt.Printf("! Warning: %s\n", w) + } + return nil }, } diff --git a/go.mod b/go.mod index 95af218..467af75 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,15 @@ module github.com/apialerts/cli -go 1.22 +go 1.25.0 require ( - github.com/apialerts/apialerts-go v1.2.0-alpha.1 + github.com/apialerts/apialerts-go v1.2.0-alpha.3 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.41.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 52b09dc..1ec9f81 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ -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/apialerts/apialerts-go v1.2.0-alpha.2/go.mod h1:8axzOXPrs/4LAEZPv4qIw9+8ccOx84h7YAqITTsZwEM= +github.com/apialerts/apialerts-go v1.2.0-alpha.3 h1:meAek+/CjLPcj0JvCzOYY5DnTb1h/SVoK4onXl7RCTw= +github.com/apialerts/apialerts-go v1.2.0-alpha.3/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= @@ -10,4 +11,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An 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= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 2cf9a5ff4a29492308adf65f2aad92d59fa9e38a Mon Sep 17 00:00:00 2001 From: Jared Hall Date: Wed, 18 Mar 2026 19:03:12 +1100 Subject: [PATCH 2/2] new commands, tests and json data support - depends on apialerts-go v1.2.0-alpha.4 --- .github/workflows/build-release.yml | 2 +- .github/workflows/publish-github.yml | 2 +- .github/workflows/pull-request.yml | 2 +- README.md | 90 +++++---- cmd/config.go | 65 +++++-- cmd/constants.go | 4 +- cmd/init.go | 36 ++-- cmd/root.go | 4 +- cmd/send.go | 35 +++- cmd/test.go | 13 +- functional_test.go | 280 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 5 +- internal/config/config.go | 5 +- 14 files changed, 452 insertions(+), 93 deletions(-) create mode 100644 functional_test.go diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 77b5186..726e54f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.22" + go-version-file: 'go.mod' - name: Test run: go test -v ./... - name: Build diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index c969fbc..8e58f78 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.22" + go-version-file: 'go.mod' - name: Import Apple certificate env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3d9f6b4..13a8e41 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: "1.22" + go-version-file: 'go.mod' - name: Test run: go test -v ./... - name: Build diff --git a/README.md b/README.md index 6ea8b94..a0e246e 100644 --- a/README.md +++ b/README.md @@ -30,79 +30,99 @@ Download the latest binary from the [Releases](https://github.com/apialerts/cli/ ## Setup -Configure your API key once. The key is stored in `~/.apialerts/config.json`. +You'll need an API key from your workspace. After logging in to [apialerts.com](https://apialerts.com), navigate to your workspace and open the **API Keys** section. You can also find it in the mobile app under your workspace settings. -```bash -apialerts config --key your_api_key -``` +Your API key is stored locally in `~/.apialerts/config.json`. -Verify your configuration +### Interactive (recommended) ```bash -apialerts config +apialerts init ``` -## Send Events +You will be prompted to paste your API key (input is hidden): -Send an event with a message +``` +Enter your API key: +API key saved: abcdef...wxyz +``` + +### Non-interactive (CI/CD or scripts) ```bash -apialerts send -m "Deploy completed" +apialerts config --key "your-api-key" ``` -Send an event with a name and title +### View your current key ```bash -apialerts send -e user.purchase -t "New Sale" -m "$49.99 from john@example.com" -c payments +apialerts config ``` -### Optional Properties +``` +API Key: abcdef...wxyz +``` -You can optionally specify an event name, title, channel, tags, and a link. +### Remove your key ```bash -apialerts send -m "Payment failed" -c payments -g billing,error -l https://dashboard.example.com +apialerts config --unset ``` -| 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) | +## Send Events -### Override API Key +```bash +apialerts send -m "Deploy completed" +``` -You can override the stored API key for a single request. +```bash +apialerts send -e "user.purchase" -t "New Sale" -m "$49.99 from john@example.com" -c "payments" +``` ```bash -apialerts send -m "Hello World" --key other_api_key +apialerts send -e "user.signup" -m "New user registered" -d '{"plan":"pro","source":"organic"}' ``` +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--message` | `-m` | Event message **(required)** | +| `--event` | `-e` | Event name for routing (e.g. `user.purchase`) | +| `--title` | `-t` | Event title | +| `--channel` | `-c` | Target channel (uses your default channel if not set) | +| `--tags` | `-g` | Comma-separated tags (e.g. `billing,error`) | +| `--link` | `-l` | Associated URL | +| `--data` | `-d` | JSON object with additional event data (e.g. `'{"plan":"pro"}'`) | +| `--key` | | API key override (uses stored config if not set) | + ## Test Connectivity -Send a test event to verify your API key and connection. +Send a test event to verify your API key and connection: ```bash apialerts test ``` -## CI/CD Examples +``` +✓ Test event sent to My Workspace (general) +``` -### GitHub Actions +## Examples -```yaml -- name: Send deploy alert - run: | - apialerts send -m "Deployed ${{ github.sha }}" -c deployments -g ci,deploy --key ${{ secrets.APIALERTS_API_KEY }} -``` +### Claude Code + +Because the CLI is installed on your machine, Claude Code can run it directly as part of any task. Just ask: + +- "Refactor the auth module and send me an API Alert when you're done." +- "Run the full test suite and notify me via API Alerts with a summary of the results." +- "Migrate the database schema and send me an apialert if anything fails." + +Claude will run `apialerts send` at the right moment — no extra configuration needed. ### Shell Script ```bash #!/bin/bash -apialerts send -m "Backup completed" -c ops -g backup,cron +apialerts send -m "Backup completed" -c "ops" -g "backup,cron" ``` diff --git a/cmd/config.go b/cmd/config.go index 54da905..8fe2fb9 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -8,15 +8,15 @@ import ( ) var configKey string +var configServerURL string +var unsetKey bool func maskAPIKey(key string) string { n := len(key) - switch { case key == "": return "No API key configured." case n <= 3: - // Too short, just mask everything return "***" case n <= 10: return key[:1] + "..." + key[n-1:] @@ -30,32 +30,71 @@ var configCmd = &cobra.Command{ 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 + if unsetKey { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + cfg.APIKey = "" + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to unset API key: %w", err) + } + fmt.Println("API key removed.") + return nil + } + + if cmd.Flags().Changed("server-url") { 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 init") + cfg.ServerURL = configServerURL + if err := config.Save(cfg); err != nil { + return fmt.Errorf("failed to save server URL: %w", err) + } + if configServerURL == "" { + fmt.Println("Server URL reset to default.") } else { - masked := maskAPIKey(cfg.APIKey) - fmt.Printf("API Key: %s\n", masked) + fmt.Printf("Server URL set to: %s\n", configServerURL) } return nil } - cfg := &config.CLIConfig{APIKey: configKey} - if err := config.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + if configKey != "" { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + cfg.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 + } + + // 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 init") + } else { + fmt.Printf("API Key: %s\n", maskAPIKey(cfg.APIKey)) + if cfg.ServerURL != "" { + fmt.Printf("Server URL: %s\n", cfg.ServerURL) + } } - fmt.Println("API key saved.") return nil }, } func init() { configCmd.Flags().StringVar(&configKey, "key", "", "Your API Alerts API key") + configCmd.Flags().BoolVar(&unsetKey, "unset", false, "Remove the stored API key") + configCmd.Flags().StringVar(&configServerURL, "server-url", "", "Override the API server URL") + configCmd.Flags().MarkHidden("server-url") rootCmd.AddCommand(configCmd) } diff --git a/cmd/constants.go b/cmd/constants.go index a720c06..f07e5a7 100644 --- a/cmd/constants.go +++ b/cmd/constants.go @@ -1,6 +1,6 @@ package cmd const ( - IntegrationName = "cli" - Version = "1.1.0" + IntegrationName = "apialerts-cli" + Version = "1.2.0" ) diff --git a/cmd/init.go b/cmd/init.go index f122d34..f2ef64f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -10,45 +10,41 @@ import ( "golang.org/x/term" ) -var initKey string - var initCmd = &cobra.Command{ Use: "init", Short: "Set up your API key", - Long: "Set your API key interactively or via flag. The key is stored in ~/.apialerts/config.json.", - Example: ` apialerts init - apialerts init --key "your-api-key"`, + Long: "Interactively prompt for your API key and save it to ~/.apialerts/config.json.", RunE: func(cmd *cobra.Command, args []string) error { - key := initKey + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("no terminal detected — use: apialerts config --key \"your-api-key\"") + } - if key == "" { - if !term.IsTerminal(int(os.Stdin.Fd())) { - return fmt.Errorf("no terminal detected — use: apialerts init --key \"your-api-key\"") - } - fmt.Print("Enter your API key: ") - keyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return fmt.Errorf("failed to read input: %w", err) - } - key = strings.TrimSpace(string(keyBytes)) + fmt.Print("Enter your API key: ") + keyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) } + key := strings.TrimSpace(string(keyBytes)) if key == "" { return fmt.Errorf("API key cannot be empty") } - cfg := &config.CLIConfig{APIKey: key} + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + cfg.APIKey = key if err := config.Save(cfg); err != nil { return fmt.Errorf("failed to save config: %w", err) } - fmt.Println("API key saved.") + fmt.Printf("API key saved: %s\n", maskAPIKey(key)) return nil }, } func init() { - initCmd.Flags().StringVar(&initKey, "key", "", "Your API Alerts API key") rootCmd.AddCommand(initCmd) } diff --git a/cmd/root.go b/cmd/root.go index 1a064cd..d26eaa2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,8 +19,10 @@ Get started: } func Execute() { + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.HiddenDefaultCmd = true if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } } diff --git a/cmd/send.go b/cmd/send.go index acd6566..b51705e 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -1,11 +1,12 @@ package cmd import ( + "encoding/json" "fmt" "strings" - "github.com/apialerts/cli/internal/config" "github.com/apialerts/apialerts-go" + "github.com/apialerts/cli/internal/config" "github.com/spf13/cobra" ) @@ -16,6 +17,7 @@ var ( sendChannel string sendTags string sendLink string + sendData string sendKey string ) @@ -31,24 +33,31 @@ Properties: -c channel Target channel (uses your default channel if not set) -g tags Comma-separated tags for filtering (e.g. billing,error) -l link URL attached to the notification + -d data JSON object with additional event data (e.g. '{"user":"john","plan":"pro"}') key API key override (uses stored config if not set)`, Example: ` apialerts send -m "Deploy completed" apialerts send -e "user.purchase" -t "New Sale" -m "$49.99 from john@example.com" -c "payments" apialerts send -m "Payment failed" -c "payments" -g "billing,error" - apialerts send -m "Build passed" -l "https://ci.example.com/build/123"`, + apialerts send -m "Build passed" -l "https://ci.example.com/build/123" + apialerts send -e "user.signup" -m "New user registered" -d '{"plan":"pro","source":"organic"}'`, RunE: func(cmd *cobra.Command, args []string) error { if sendMessage == "" { return fmt.Errorf("message is required — use -m \"your message\"") } + // Load config once + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + // Resolve API key: flag > config file apiKey := sendKey if apiKey == "" { - key, err := config.GetAPIKey() - if err != nil { - return err + if cfg.APIKey == "" { + return fmt.Errorf("no API key configured — run: apialerts init") } - apiKey = key + apiKey = cfg.APIKey } // Parse tags @@ -62,9 +71,17 @@ Properties: } } + // Parse data JSON + var data map[string]any + if sendData != "" { + if err := json.Unmarshal([]byte(sendData), &data); err != nil { + return fmt.Errorf("invalid JSON for --data: %w", err) + } + } + // Configure and send - apialerts.ConfigureWithConfig(apiKey, apialerts.Config{Debug: true}) - apialerts.SetIntegration(IntegrationName) + apialerts.Configure(apiKey) + apialerts.SetOverrides(IntegrationName, Version, cfg.ServerURL) event := apialerts.Event{ Event: sendEvent, @@ -73,6 +90,7 @@ Properties: Channel: sendChannel, Tags: tags, Link: sendLink, + Data: data, } result, err := apialerts.SendAsync(event) @@ -96,6 +114,7 @@ func init() { 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().StringVarP(&sendData, "data", "d", "", "JSON object with additional event data") 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 index 1f1a2de..e63fedb 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -3,8 +3,8 @@ package cmd import ( "fmt" - "github.com/apialerts/cli/internal/config" "github.com/apialerts/apialerts-go" + "github.com/apialerts/cli/internal/config" "github.com/spf13/cobra" ) @@ -13,13 +13,16 @@ var testCmd = &cobra.Command{ 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() + cfg, err := config.Load() if err != nil { - return err + return fmt.Errorf("failed to load config: %w", err) + } + if cfg.APIKey == "" { + return fmt.Errorf("no API key configured — run: apialerts init") } - apialerts.ConfigureWithConfig(apiKey, apialerts.Config{Debug: true}) - apialerts.SetIntegration(IntegrationName) + apialerts.Configure(cfg.APIKey) + apialerts.SetOverrides(IntegrationName, Version, cfg.ServerURL) event := apialerts.Event{ Event: "cli.test", diff --git a/functional_test.go b/functional_test.go new file mode 100644 index 0000000..cf16307 --- /dev/null +++ b/functional_test.go @@ -0,0 +1,280 @@ +package main_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var binaryPath string + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "apialerts-functional-*") + if err != nil { + panic("failed to create temp dir: " + err.Error()) + } + defer os.RemoveAll(tmp) + + binaryPath = filepath.Join(tmp, "apialerts") + cmd := exec.Command("go", "build", "-o", binaryPath, ".") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic("failed to build binary: " + err.Error()) + } + + os.Exit(m.Run()) +} + +type result struct { + stdout string + stderr string + exitCode int +} + +func run(t *testing.T, homeDir string, args ...string) result { + t.Helper() + cmd := exec.Command(binaryPath, args...) + cmd.Env = []string{"HOME=" + homeDir} + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + exitCode := 0 + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + return result{ + stdout: strings.TrimSpace(stdout.String()), + stderr: strings.TrimSpace(stderr.String()), + exitCode: exitCode, + } +} + +func mockServer(t *testing.T, statusCode int, body map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if body != nil { + json.NewEncoder(w).Encode(body) + } + })) +} + +// --- Config tests --- + +func TestConfigNoKey(t *testing.T) { + home := t.TempDir() + r := run(t, home, "config") + if !strings.Contains(r.stdout, "No API key configured") { + t.Errorf("expected 'No API key configured', got: %q", r.stdout) + } + if !strings.Contains(r.stdout, "apialerts init") { + t.Errorf("expected hint to run 'apialerts init', got: %q", r.stdout) + } +} + +func TestConfigSetKey(t *testing.T) { + home := t.TempDir() + r := run(t, home, "config", "--key", "testapikey12345678") + if !strings.Contains(r.stdout, "API key saved") { + t.Errorf("expected 'API key saved', got: %q", r.stdout) + } +} + +func TestConfigViewMaskedKey(t *testing.T) { + home := t.TempDir() + run(t, home, "config", "--key", "testapikey12345678") + r := run(t, home, "config") + if !strings.Contains(r.stdout, "API Key:") { + t.Errorf("expected masked key, got: %q", r.stdout) + } + if strings.Contains(r.stdout, "testapikey12345678") { + t.Errorf("expected key to be masked, got full key: %q", r.stdout) + } +} + +func TestConfigUnsetKey(t *testing.T) { + home := t.TempDir() + run(t, home, "config", "--key", "testapikey12345678") + r := run(t, home, "config", "--unset") + if !strings.Contains(r.stdout, "API key removed") { + t.Errorf("expected 'API key removed', got: %q", r.stdout) + } + r = run(t, home, "config") + if !strings.Contains(r.stdout, "No API key configured") { + t.Errorf("expected 'No API key configured' after unset, got: %q", r.stdout) + } +} + +// --- Init tests --- + +func TestInitNoTTY(t *testing.T) { + home := t.TempDir() + r := run(t, home, "init") + if r.exitCode == 0 { + t.Error("expected non-zero exit code when no TTY") + } + if !strings.Contains(r.stderr, "no terminal detected") { + t.Errorf("expected 'no terminal detected' error, got: %q", r.stderr) + } +} + +// --- Send validation tests --- + +func TestSendNoMessage(t *testing.T) { + home := t.TempDir() + r := run(t, home, "send") + if r.exitCode == 0 { + t.Error("expected non-zero exit code when no message") + } + if !strings.Contains(r.stderr, "message is required") { + t.Errorf("expected 'message is required', got: %q", r.stderr) + } +} + +func TestSendEmptyMessage(t *testing.T) { + home := t.TempDir() + r := run(t, home, "send", "-m", "") + if r.exitCode == 0 { + t.Error("expected non-zero exit code when message is empty") + } + if !strings.Contains(r.stderr, "message is required") { + t.Errorf("expected 'message is required', got: %q", r.stderr) + } +} + +func TestSendNoKey(t *testing.T) { + home := t.TempDir() + r := run(t, home, "send", "-m", "hello") + if r.exitCode == 0 { + t.Error("expected non-zero exit code when no API key") + } + if !strings.Contains(r.stderr, "no API key configured") { + t.Errorf("expected 'no API key configured', got: %q", r.stderr) + } +} + +// --- Send HTTP tests --- + +func TestSendSuccess(t *testing.T) { + server := mockServer(t, http.StatusOK, map[string]any{ + "workspace": "Acme Corp", + "channel": "general", + }) + defer server.Close() + + home := t.TempDir() + run(t, home, "config", "--server-url", server.URL) + r := run(t, home, "send", "-m", "Deploy complete", "--key", "fake-api-key") + if r.exitCode != 0 { + t.Errorf("expected success, got exit code %d, stderr: %q", r.exitCode, r.stderr) + } + if !strings.Contains(r.stdout, "Acme Corp") { + t.Errorf("expected workspace in output, got: %q", r.stdout) + } + if !strings.Contains(r.stdout, "general") { + t.Errorf("expected channel in output, got: %q", r.stdout) + } +} + +func TestSendUnauthorized(t *testing.T) { + server := mockServer(t, http.StatusUnauthorized, nil) + defer server.Close() + + home := t.TempDir() + run(t, home, "config", "--server-url", server.URL) + r := run(t, home, "send", "-m", "Deploy complete", "--key", "bad-key") + if r.exitCode == 0 { + t.Error("expected non-zero exit code for unauthorized") + } + if !strings.Contains(r.stderr, "unauthorized") { + t.Errorf("expected 'unauthorized' in error, got: %q", r.stderr) + } +} + +func TestSendRateLimit(t *testing.T) { + server := mockServer(t, http.StatusTooManyRequests, nil) + defer server.Close() + + home := t.TempDir() + run(t, home, "config", "--server-url", server.URL) + r := run(t, home, "send", "-m", "Deploy complete", "--key", "fake-api-key") + if r.exitCode == 0 { + t.Error("expected non-zero exit code for rate limit") + } + if !strings.Contains(r.stderr, "rate limit") { + t.Errorf("expected 'rate limit' in error, got: %q", r.stderr) + } +} + +func TestSendWithData(t *testing.T) { + server := mockServer(t, http.StatusOK, map[string]any{ + "workspace": "Acme Corp", + "channel": "general", + }) + defer server.Close() + + home := t.TempDir() + run(t, home, "config", "--server-url", server.URL) + r := run(t, home, "send", "-m", "New signup", "-d", `{"plan":"pro","source":"organic"}`, "--key", "fake-api-key") + if r.exitCode != 0 { + t.Errorf("expected success, got exit code %d, stderr: %q", r.exitCode, r.stderr) + } + if !strings.Contains(r.stdout, "Acme Corp") { + t.Errorf("expected workspace in output, got: %q", r.stdout) + } +} + +func TestSendWithInvalidData(t *testing.T) { + home := t.TempDir() + r := run(t, home, "send", "-m", "hello", "-d", `not valid json`, "--key", "fake-api-key") + if r.exitCode == 0 { + t.Error("expected non-zero exit code for invalid JSON") + } + if !strings.Contains(r.stderr, "invalid JSON") { + t.Errorf("expected 'invalid JSON' error, got: %q", r.stderr) + } +} + +// --- Test command HTTP tests --- + +func TestTestCommandSuccess(t *testing.T) { + server := mockServer(t, http.StatusOK, map[string]any{ + "workspace": "Acme Corp", + "channel": "general", + }) + defer server.Close() + + home := t.TempDir() + run(t, home, "config", "--key", "fake-api-key") + run(t, home, "config", "--server-url", server.URL) + r := run(t, home, "test") + if r.exitCode != 0 { + t.Errorf("expected success, got exit code %d, stderr: %q", r.exitCode, r.stderr) + } + if !strings.Contains(r.stdout, "Acme Corp") { + t.Errorf("expected workspace in output, got: %q", r.stdout) + } +} + +func TestTestCommandNoKey(t *testing.T) { + home := t.TempDir() + r := run(t, home, "test") + if r.exitCode == 0 { + t.Error("expected non-zero exit code when no API key") + } + if !strings.Contains(r.stderr, "no API key configured") { + t.Errorf("expected 'no API key configured', got: %q", r.stderr) + } +} diff --git a/go.mod b/go.mod index 467af75..50cb3da 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/apialerts/cli go 1.25.0 require ( - github.com/apialerts/apialerts-go v1.2.0-alpha.3 + github.com/apialerts/apialerts-go v1.2.0-alpha.4 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.41.0 ) diff --git a/go.sum b/go.sum index 1ec9f81..c224dc2 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ -github.com/apialerts/apialerts-go v1.2.0-alpha.2/go.mod h1:8axzOXPrs/4LAEZPv4qIw9+8ccOx84h7YAqITTsZwEM= -github.com/apialerts/apialerts-go v1.2.0-alpha.3 h1:meAek+/CjLPcj0JvCzOYY5DnTb1h/SVoK4onXl7RCTw= -github.com/apialerts/apialerts-go v1.2.0-alpha.3/go.mod h1:8axzOXPrs/4LAEZPv4qIw9+8ccOx84h7YAqITTsZwEM= +github.com/apialerts/apialerts-go v1.2.0-alpha.4 h1:fvx6yqlRmmtQwkH3rqPu+qntORMCojZ1AERutx93or8= +github.com/apialerts/apialerts-go v1.2.0-alpha.4/go.mod h1:HhLAxT5uQOZUZLXYPSrQJduUYj+yzB6yU3/KjcDT9hU= 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= diff --git a/internal/config/config.go b/internal/config/config.go index 8fcc947..a6733fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,8 @@ const configFile = "config.json" var configDirOverride string type CLIConfig struct { - APIKey string `json:"api_key"` + APIKey string `json:"api_key"` + ServerURL string `json:"server_url,omitempty"` } func configPath() (string, error) { @@ -73,7 +74,7 @@ func GetAPIKey() (string, error) { return "", err } if cfg.APIKey == "" { - return "", errors.New("no API key configured — run: apialerts config --key ") + return "", errors.New("no API key configured — run: apialerts init") } return cfg.APIKey, nil }