diff --git a/cmd/openstatus/main.go b/cmd/openstatus/main.go index d8e6431..d705c4d 100644 --- a/cmd/openstatus/main.go +++ b/cmd/openstatus/main.go @@ -1,16 +1,17 @@ package main import ( - "context" cmd "github.com/openstatusHQ/cli/internal/cmd" + "github.com/joho/godotenv" "log" - "os" ) func main() { + _ = godotenv.Load() + app := cmd.NewApp() - if err := app.Run(context.Background(), os.Args); err != nil { + if err := cmd.RunApp(app); err != nil { log.Fatal(err) } } diff --git a/go.mod b/go.mod index 35cf0a4..c794657 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,19 @@ require ( buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2 buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1 connectrpc.com/connect v1.19.1 + github.com/briandowns/spinner v1.23.2 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 + github.com/joho/godotenv v1.5.1 github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/file v1.1.2 github.com/knadh/koanf/v2 v2.1.1 github.com/logrusorgru/aurora/v4 v4.0.0 + github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v1.0.7 github.com/rodaine/table v1.3.0 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 + golang.org/x/term v0.1.0 sigs.k8s.io/yaml v1.4.0 ) @@ -27,7 +31,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect diff --git a/go.sum b/go.sum index 7250bbe..269cf5a 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2 h1:nQzgK01nlgbSQn/7/qjHxSwcv/C6I2f9lMfhgJ98FV0= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2/go.mod h1:ikyyG3mJiNpeGcAywdhzGOt5fTSs78jvlvAqJo8ZYf4= buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2 h1:MeP+r7GwYHWKSMa1ltvtRNwzT+gCyHIYCEpNxWeNwS4= buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2/go.mod h1:W/PtF1QguqXdSkOHAD0VAOTMNfuESeNOdR2cF/CWeOQ= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1 h1:PeaMjGloj9U860iUOmX7pNwx2hdudlOus8ietWw7IWE= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM= buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1 h1:vw4PznfU8x7XrFtc/HHPjWfxNnFExtaSwrPS8cEKq+w= buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,6 +23,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= @@ -77,6 +77,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/api/client.go b/internal/api/client.go index 05cc9e8..f07e4bd 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -2,7 +2,11 @@ package api import ( "context" + "fmt" + "os" + "time" + output "github.com/openstatusHQ/cli/internal/cli" "connectrpc.com/connect" ) @@ -14,7 +18,24 @@ func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { req.Header().Set("x-openstatus-key", apiKey) - return next(ctx, req) + + if output.IsDebug() { + fmt.Fprintf(os.Stderr, "[debug] %s %s\n", req.HTTPMethod(), req.Spec().Procedure) + } + + start := time.Now() + resp, err := next(ctx, req) + + if output.IsDebug() { + duration := time.Since(start) + if err != nil { + fmt.Fprintf(os.Stderr, "[debug] error after %s: %v\n", duration, err) + } else { + fmt.Fprintf(os.Stderr, "[debug] ok in %s\n", duration) + } + } + + return resp, err } } } diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..ab89677 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,90 @@ +package auth + +import ( + "fmt" + "os" + "strings" + + "github.com/openstatusHQ/cli/internal/config" + clilib "github.com/urfave/cli/v3" +) + +// ResolveAccessToken extracts the access token from CLI flags or falls back to saved token. +func ResolveAccessToken(cmd *clilib.Command) (string, error) { + return ResolveToken(cmd.String("access-token")) +} + +func ResolveToken(flagValue string) (string, error) { + if flagValue != "" { + return flagValue, nil + } + + tokenPath, err := config.TokenPath() + if err == nil { + data, readErr := os.ReadFile(tokenPath) + if readErr == nil { + token := strings.TrimSpace(string(data)) + if token != "" { + return token, nil + } + } + } + + return "", fmt.Errorf("no API token found. Set OPENSTATUS_API_TOKEN env var, or run 'openstatus login'") +} + +func SaveToken(token string) error { + dir, err := config.ConfigDir() + if err != nil { + return fmt.Errorf("failed to determine config directory: %w", err) + } + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + tokenPath, err := config.TokenPath() + if err != nil { + return fmt.Errorf("failed to determine token path: %w", err) + } + + tmpFile, err := os.CreateTemp(dir, ".token-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(token); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to write token: %w", err) + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + if err := os.Chmod(tmpPath, 0600); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to set token file permissions: %w", err) + } + if err := os.Rename(tmpPath, tokenPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to save token: %w", err) + } + return nil +} + +func RemoveToken() error { + tokenPath, err := config.TokenPath() + if err != nil { + return fmt.Errorf("failed to determine token path: %w", err) + } + err = os.Remove(tokenPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("failed to remove token: %w", err) + } + return nil +} + diff --git a/internal/cli/errors.go b/internal/cli/errors.go new file mode 100644 index 0000000..03ea1b0 --- /dev/null +++ b/internal/cli/errors.go @@ -0,0 +1,51 @@ +package cli + +import ( + "errors" + "fmt" + "net" + "strings" + + "connectrpc.com/connect" +) + +func FormatError(err error, resource string, id string) error { + if err == nil { + return nil + } + + var connectErr *connect.Error + if errors.As(err, &connectErr) { + switch connectErr.Code() { + case connect.CodeUnauthenticated: + return fmt.Errorf("authentication failed. Check your API token via OPENSTATUS_API_TOKEN env var or --access-token flag. Verify with 'openstatus whoami'") + case connect.CodePermissionDenied: + return fmt.Errorf("permission denied. Check that your API token has access to this workspace") + case connect.CodeNotFound: + if id != "" { + return fmt.Errorf("%s %s not found. Run 'openstatus %s list' to see available %ss", resource, id, resource, resource) + } + return fmt.Errorf("%s not found", resource) + case connect.CodeResourceExhausted: + return fmt.Errorf("rate limited. Wait a moment and try again") + case connect.CodeInvalidArgument: + return fmt.Errorf("invalid request: %s", connectErr.Message()) + } + } + + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + } + + var netErr *net.OpError + if errors.As(err, &netErr) { + return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + } + + if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "no such host") { + return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection") + } + + return err +} diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..4cfe39a --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,43 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "sync/atomic" + + "github.com/fatih/color" + "github.com/mattn/go-isatty" +) + +var ( + jsonOutput atomic.Bool + quietMode atomic.Bool + debugMode atomic.Bool +) + +func SetJSONOutput(v bool) { jsonOutput.Store(v) } +func SetQuietMode(v bool) { quietMode.Store(v) } +func SetDebugMode(v bool) { debugMode.Store(v) } +func IsJSONOutput() bool { return jsonOutput.Load() } +func IsQuiet() bool { return quietMode.Load() } +func IsDebug() bool { return debugMode.Load() } +func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } +func IsStderrTerminal() bool { + return isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) +} + +func PrintJSON(v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + return nil +} + +func InitColorSettings(noColorFlag bool) { + if noColorFlag || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" || !IsTerminal() { + color.NoColor = true + } +} diff --git a/internal/cli/pager.go b/internal/cli/pager.go new file mode 100644 index 0000000..d343702 --- /dev/null +++ b/internal/cli/pager.go @@ -0,0 +1,48 @@ +package cli + +import ( + "io" + "os" + "os/exec" + "strings" +) + +// WithPager pipes output through the user's $PAGER (default: "less -FIRX"). +// Note: $PAGER is split by whitespace (strings.Fields), so paths with spaces +// are not supported. This matches the behavior of git and gh. +func WithPager(fn func(w io.Writer)) { + if !IsTerminal() || IsJSONOutput() || IsQuiet() { + fn(os.Stdout) + return + } + + pager := os.Getenv("PAGER") + if pager == "" { + pager = "less -FIRX" + } + + parts := strings.Fields(pager) + if len(parts) == 0 { + fn(os.Stdout) + return + } + + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + w, err := cmd.StdinPipe() + if err != nil { + fn(os.Stdout) + return + } + + if err := cmd.Start(); err != nil { + fn(os.Stdout) + return + } + + fn(w) + w.Close() + cmd.Wait() +} diff --git a/internal/cli/spinner.go b/internal/cli/spinner.go new file mode 100644 index 0000000..c1b4bfa --- /dev/null +++ b/internal/cli/spinner.go @@ -0,0 +1,29 @@ +package cli + +import ( + "fmt" + "os" + "time" + + "github.com/briandowns/spinner" +) + +// Spinner is a type alias so callers don't need to import the spinner package directly. +type Spinner = spinner.Spinner + +func StartSpinner(message string) *Spinner { + if !IsStderrTerminal() || IsJSONOutput() || IsQuiet() { + return nil + } + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Suffix = " " + message + s.Start() + return s +} + +func StopSpinner(s *spinner.Spinner) { + if s != nil { + s.Stop() + fmt.Fprintln(os.Stderr) + } +} diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 716c8b4..386fb52 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -1,6 +1,13 @@ package cmd import ( + "context" + "os" + "os/signal" + "syscall" + + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/login" "github.com/openstatusHQ/cli/internal/monitors" "github.com/openstatusHQ/cli/internal/run" "github.com/openstatusHQ/cli/internal/statusreport" @@ -10,17 +17,75 @@ import ( func NewApp() *cli.Command { app := &cli.Command{ - Name: "openstatus", - Suggest: true, - Usage: "This is OpenStatus Command Line Interface, the OpenStatus.dev CLI", - Description: "OpenStatus is a command line interface for managing your monitors and triggering your synthetics tests. \n\nPlease report any issues at https://github.com/openstatusHQ/cli/issues/new", + Name: "openstatus", + Suggest: true, + EnableShellCompletion: true, + Usage: "Manage status pages, monitors, and incidents from the terminal", + Description: `OpenStatus CLI lets you manage your status pages and uptime monitors +from the command line. Report and track incidents, define monitors as code, +and run on-demand checks. + +Get started: + openstatus login Save your API token + openstatus status-report create Report an incident + openstatus status-report list View active incidents + openstatus monitors apply Sync monitors from config + openstatus monitors list List your monitors + openstatus run Run synthetic tests + +https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, Version: "v1.0.1", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Usage: "Output results as JSON", + }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable colored output", + }, + &cli.BoolFlag{ + Name: "quiet", + Usage: "Suppress non-error output", + Aliases: []string{"q"}, + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "Enable debug output", + }, + }, + Before: func(ctx context.Context, cmd *cli.Command) error { + output.SetJSONOutput(cmd.Bool("json")) + output.SetQuietMode(cmd.Bool("quiet")) + output.SetDebugMode(cmd.Bool("debug")) + output.InitColorSettings(cmd.Bool("no-color")) + return nil + }, Commands: []*cli.Command{ monitors.MonitorsCmd(), statusreport.StatusReportCmd(), run.RunCmd(), whoami.WhoamiCmd(), + login.LoginCmd(), + login.LogoutCmd(), }, } return app } + +func RunApp(app *cli.Command) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + stop() + // Second signal: force exit + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + os.Exit(130) + }() + + return app.Run(ctx, os.Args) +} diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index 103b696..06df33a 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -32,8 +32,8 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 4 { - t.Errorf("Expected 4 commands, got %d", len(app.Commands)) + if len(app.Commands) != 6 { + t.Errorf("Expected 6 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ @@ -41,6 +41,8 @@ func Test_NewApp(t *testing.T) { "status-report": false, "run": false, "whoami": false, + "login": false, + "logout": false, } for _, subcmd := range app.Commands { @@ -59,7 +61,7 @@ func Test_NewApp(t *testing.T) { t.Run("Has correct usage text", func(t *testing.T) { app := cmd.NewApp() - expectedUsage := "This is OpenStatus Command Line Interface, the OpenStatus.dev CLI" + expectedUsage := "Manage status pages, monitors, and incidents from the terminal" if app.Usage != expectedUsage { t.Errorf("Expected usage '%s', got '%s'", expectedUsage, app.Usage) } diff --git a/internal/config/xdg.go b/internal/config/xdg.go new file mode 100644 index 0000000..d4bf221 --- /dev/null +++ b/internal/config/xdg.go @@ -0,0 +1,34 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +func ConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "openstatus"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, ".config", "openstatus"), nil +} + +func TokenPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "token"), nil +} + +func UserConfigPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} diff --git a/internal/login/login.go b/internal/login/login.go new file mode 100644 index 0000000..0fbbcc6 --- /dev/null +++ b/internal/login/login.go @@ -0,0 +1,83 @@ +package login + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "strings" + + "github.com/openstatusHQ/cli/internal/auth" + "github.com/openstatusHQ/cli/internal/whoami" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +func LoginCmd() *cli.Command { + return &cli.Command{ + Name: "login", + Usage: "Save your API token", + UsageText: "openstatus login", + Description: `Saves your OpenStatus API token for use in subsequent commands. +Get your API token from the OpenStatus dashboard.`, + Action: func(ctx context.Context, cmd *cli.Command) error { + interactive := term.IsTerminal(int(os.Stdin.Fd())) + + if interactive { + fmt.Fprintln(os.Stderr, "Enter your OpenStatus API token (from your dashboard):") + fmt.Fprint(os.Stderr, "> ") + } + + var token string + if interactive { + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return cli.Exit("Failed to read token", 1) + } + token = string(raw) + fmt.Fprintln(os.Stderr) + } else { + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return cli.Exit("Failed to read token", 1) + } + token = line + } + + token = strings.TrimSpace(token) + if token == "" { + return cli.Exit("Token cannot be empty", 1) + } + + fmt.Fprintln(os.Stderr, "Verifying token...") + err := whoami.GetWhoamiCmd(ctx, http.DefaultClient, token, nil) + if err != nil { + return cli.Exit("Invalid token. Could not authenticate with OpenStatus API", 1) + } + + if err := auth.SaveToken(token); err != nil { + return cli.Exit(fmt.Sprintf("Failed to save token: %v", err), 1) + } + + fmt.Println("Token saved successfully. You can now use openstatus commands without --access-token") + return nil + }, + } +} + +func LogoutCmd() *cli.Command { + return &cli.Command{ + Name: "logout", + Usage: "Remove saved API token", + UsageText: "openstatus logout", + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := auth.RemoveToken(); err != nil { + return cli.Exit(fmt.Sprintf("Failed to remove token: %v", err), 1) + } + fmt.Println("Token removed successfully") + return nil + }, + } +} diff --git a/internal/monitors/monitor_apply.go b/internal/monitors/monitor_apply.go index 31c12b7..6e813ea 100644 --- a/internal/monitors/monitor_apply.go +++ b/internal/monitors/monitor_apply.go @@ -8,115 +8,101 @@ import ( "os" "github.com/google/go-cmp/cmp" - confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" "sigs.k8s.io/yaml" ) -func CompareLockWithConfig(apiKey string, applyChange bool, lock config.MonitorsLock, configData config.Monitors) (config.MonitorsLock, error) { +// countChanges computes the number of creates, updates, and deletes without making API calls. +func countChanges(lock config.MonitorsLock, configData config.Monitors) (created, updated, deleted int) { + for v, configValue := range configData { + value, exist := lock[v] + if !exist { + created++ + } else if !cmp.Equal(configValue, value.Monitor) { + updated++ + } + } + for v := range lock { + if _, exist := configData[v]; !exist { + deleted++ + } + } + return +} +// ApplyChanges applies the changes between the lock file and the config data, making API calls. +func ApplyChanges(ctx context.Context, apiKey string, lock config.MonitorsLock, configData config.Monitors) (config.MonitorsLock, error) { var created, updated, deleted int - // Create or update monitors + for v, configValue := range configData { value, exist := lock[v] if !exist { - - if applyChange { - - result, err := CreateMonitor(http.DefaultClient, apiKey, configValue) - if err != nil { - return nil, err - } - lock[v] = config.Lock{ - ID: result.ID, - Monitor: configValue, - } + result, err := CreateMonitor(ctx, http.DefaultClient, apiKey, configValue) + if err != nil { + return nil, err + } + lock[v] = config.Lock{ + ID: result.ID, + Monitor: configValue, } - created++ - continue } if !cmp.Equal(configValue, value.Monitor) { - if applyChange { - - result, err := UpdateMonitor(http.DefaultClient, apiKey, value.ID, configValue) - if err != nil { - return nil, err - } - lock[v] = config.Lock{ - ID: result.ID, - Monitor: configValue, - } + result, err := UpdateMonitor(ctx, http.DefaultClient, apiKey, value.ID, configValue) + if err != nil { + return nil, err + } + lock[v] = config.Lock{ + ID: result.ID, + Monitor: configValue, } updated++ continue } } - // Delete monitors for v, value := range lock { if _, exist := configData[v]; !exist { - if applyChange { - - err := DeleteMonitorWithHTTPClient(http.DefaultClient, apiKey, fmt.Sprintf("%d", value.ID)) - if err != nil { - fmt.Println(err) - } - delete(lock, v) + err := DeleteMonitorWithHTTPClient(ctx, http.DefaultClient, apiKey, fmt.Sprintf("%d", value.ID)) + if err != nil { + return nil, fmt.Errorf("failed to delete monitor %d: %w", value.ID, err) } + delete(lock, v) deleted++ } } if created == 0 && updated == 0 && deleted == 0 { - fmt.Println("No change founded") return nil, nil } - if applyChange { - fmt.Println("Successfully apply") - // if created > 0 { - // fmt.Println("Monitor Created:", created) - // } - // if updated > 0 { - // fmt.Println("Monitor Updated:", updated) - // } - // if deleted > 0 { - // fmt.Println("Monitor Deleted:", deleted) - // } - - return lock, nil - } - fmt.Println("This will apply the following change:") + fmt.Println("Changes applied successfully") if created > 0 { - fmt.Println("Monitor Create:", created) + fmt.Println(" Created:", created) } if updated > 0 { - fmt.Println("Monitor Update:", updated) + fmt.Println(" Updated:", updated) } if deleted > 0 { - fmt.Println("Monitor Delete:", deleted) - } - - confirmed, err := confirmation.AskForConfirmation("Do you want to continue?") - if err != nil { - return nil, fmt.Errorf("failed to read user input: %w", err) - } - if !confirmed { - return nil, nil + fmt.Println(" Deleted:", deleted) } return lock, nil } func GetMonitorsApplyCmd() *cli.Command { - monitorsListCmd := cli.Command{ - Name: "apply", - Usage: "Create or update monitors", - Description: "Creates or updates monitors according to the OpenStatus configuration file", - UsageText: "openstatus monitors apply [options]", + monitorsApplyCmd := cli.Command{ + Name: "apply", + Usage: "Create or update monitors", + Description: `Creates or updates monitors according to the OpenStatus configuration file. +Compares your openstatus.yaml with the current state and applies changes.`, + UsageText: `openstatus monitors apply + openstatus monitors apply --config custom.yaml -y + openstatus monitors apply --dry-run`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "config", @@ -126,11 +112,10 @@ func GetMonitorsApplyCmd() *cli.Command { Value: "openstatus.yaml", }, &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.BoolFlag{ Name: "auto-accept", @@ -138,8 +123,17 @@ func GetMonitorsApplyCmd() *cli.Command { Aliases: []string{"y"}, Required: false, }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "Show what would be changed without applying", + Aliases: []string{"n"}, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } path := cmd.String("config") @@ -149,57 +143,77 @@ func GetMonitorsApplyCmd() *cli.Command { } } - // Read Config file - // monitors, err := config.ReadOpenStatus(path) if err != nil { return cli.Exit("Unable to read config file", 1) } lock, err := config.ReadLockFile("openstatus.lock") - if err != nil { return cli.Exit("Unable to read lock file", 1) } - accept := cmd.Bool("auto-accept") - if !accept { - r, err := CompareLockWithConfig(cmd.String("access-token"), false, lock, monitors) - if err != nil { - return cli.Exit("Failed to apply change", 1) + created, updated, deleted := countChanges(lock, monitors) + if created == 0 && updated == 0 && deleted == 0 { + fmt.Println("No changes found") + return nil + } + + fmt.Println("This will apply the following changes:") + if created > 0 { + fmt.Println(" Create:", created) + } + if updated > 0 { + fmt.Println(" Update:", updated) + } + if deleted > 0 { + fmt.Println(" Delete:", deleted) + } + if cmd.Bool("dry-run") { + return nil + } + + if !cmd.Bool("auto-accept") { + confirmed, err := output.AskForConfirmation("Do you want to continue?") + if err != nil { + return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) } - if r == nil { + if !confirmed { return nil } } - newLock, err := CompareLockWithConfig(cmd.String("access-token"), true, lock, monitors) + s := output.StartSpinner("Applying changes...") + newLock, err := ApplyChanges(ctx, apiKey, lock, monitors) + output.StopSpinner(s) if err != nil { - return cli.Exit("Failed to apply change", 1) + return cli.Exit(fmt.Sprintf("Failed to apply changes: %v", err), 1) } if newLock == nil { - fmt.Println("No change founded") + fmt.Println("No changes found") return nil } y, err := yaml.Marshal(&newLock) if err != nil { - return cli.Exit("Failed to apply change", 1) + return cli.Exit("Failed to marshal lock file", 1) } - // Write Lock file file, err := os.OpenFile("openstatus.lock", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - return cli.Exit("Failed to apply change", 1) - + return cli.Exit("Failed to open lock file", 1) } defer file.Close() _, err = file.Write(y) if err != nil { - return cli.Exit("Failed to apply change", 1) + return cli.Exit("Failed to write lock file", 1) + } + if err := file.Sync(); err != nil { + return cli.Exit("Failed to sync lock file", 1) } + fmt.Println("\nRun 'openstatus monitors list' to see your monitors") return nil }, } - return &monitorsListCmd + return &monitorsApplyCmd } diff --git a/internal/monitors/monitor_apply_test.go b/internal/monitors/monitor_apply_test.go index d588481..d33b3b1 100644 --- a/internal/monitors/monitor_apply_test.go +++ b/internal/monitors/monitor_apply_test.go @@ -1,36 +1,14 @@ package monitors_test import ( - "bytes" - "log" - "os" + "context" "testing" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" ) -// setupStdinWithInput creates a pipe to simulate stdin with the given input -func setupStdinWithInput(t *testing.T, input string) func() { - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - - oldStdin := os.Stdin - os.Stdin = r - - go func() { - w.WriteString(input) - w.Close() - }() - - return func() { - os.Stdin = oldStdin - } -} - -func Test_CompareLockWithConfig(t *testing.T) { +func Test_ApplyChanges(t *testing.T) { t.Run("No changes detected", func(t *testing.T) { monitor := config.Monitor{ Name: "Test Monitor", @@ -55,13 +33,7 @@ func Test_CompareLockWithConfig(t *testing.T) { "test-monitor": monitor, } - var bf bytes.Buffer - log.SetOutput(&bf) - t.Cleanup(func() { - log.SetOutput(os.Stdout) - }) - - result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) + result, err := monitors.ApplyChanges(context.Background(), "test-api-key", lock, configData) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -70,247 +42,4 @@ func Test_CompareLockWithConfig(t *testing.T) { t.Errorf("Expected nil result when no changes, got %v", result) } }) - - t.Run("Detects new monitor to create and user declines", func(t *testing.T) { - lock := config.MonitorsLock{} - - configData := config.Monitors{ - "new-monitor": { - Name: "New Monitor", - Active: true, - Frequency: config.The5M, - Kind: config.HTTP, - Regions: []config.Region{config.Ams}, - Request: config.Request{ - URL: "https://new.example.com", - Method: config.Get, - }, - }, - } - - var bf bytes.Buffer - log.SetOutput(&bf) - t.Cleanup(func() { - log.SetOutput(os.Stdout) - }) - - // Setup stdin to decline the confirmation - cleanup := setupStdinWithInput(t, "n\n") - defer cleanup() - - // When applyChange is false, it should detect the creation needed - // and ask for confirmation (which we decline with "n") - result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Result should be nil because user declined - if result != nil { - t.Errorf("Expected nil result when user declines, got %v", result) - } - }) - - t.Run("Detects monitor update needed and user declines", func(t *testing.T) { - originalMonitor := config.Monitor{ - Name: "Test Monitor", - Active: true, - Frequency: config.The10M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad}, - Request: config.Request{ - URL: "https://example.com", - Method: config.Get, - }, - } - - updatedMonitor := config.Monitor{ - Name: "Test Monitor Updated", - Active: true, - Frequency: config.The5M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad, config.Ams}, - Request: config.Request{ - URL: "https://example.com", - Method: config.Get, - }, - } - - lock := config.MonitorsLock{ - "test-monitor": { - ID: 123, - Monitor: originalMonitor, - }, - } - - configData := config.Monitors{ - "test-monitor": updatedMonitor, - } - - var bf bytes.Buffer - log.SetOutput(&bf) - t.Cleanup(func() { - log.SetOutput(os.Stdout) - }) - - // Setup stdin to decline the confirmation - cleanup := setupStdinWithInput(t, "n\n") - defer cleanup() - - result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Result should be nil because user declined - if result != nil { - t.Errorf("Expected nil result when user declines, got %v", result) - } - }) - - t.Run("Detects monitor deletion needed and user declines", func(t *testing.T) { - monitor := config.Monitor{ - Name: "Test Monitor", - Active: true, - Frequency: config.The10M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad}, - Request: config.Request{ - URL: "https://example.com", - Method: config.Get, - }, - } - - lock := config.MonitorsLock{ - "test-monitor": { - ID: 123, - Monitor: monitor, - }, - } - - configData := config.Monitors{} - - var bf bytes.Buffer - log.SetOutput(&bf) - t.Cleanup(func() { - log.SetOutput(os.Stdout) - }) - - // Setup stdin to decline the confirmation - cleanup := setupStdinWithInput(t, "n\n") - defer cleanup() - - result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Result should be nil because user declined - if result != nil { - t.Errorf("Expected nil result when user declines, got %v", result) - } - }) - - t.Run("Mixed changes detected and user declines", func(t *testing.T) { - existingMonitor := config.Monitor{ - Name: "Existing Monitor", - Active: true, - Frequency: config.The10M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad}, - Request: config.Request{ - URL: "https://existing.example.com", - Method: config.Get, - }, - } - - toUpdateMonitor := config.Monitor{ - Name: "To Update Monitor", - Active: true, - Frequency: config.The10M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad}, - Request: config.Request{ - URL: "https://update.example.com", - Method: config.Get, - }, - } - - toDeleteMonitor := config.Monitor{ - Name: "To Delete Monitor", - Active: true, - Frequency: config.The10M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad}, - Request: config.Request{ - URL: "https://delete.example.com", - Method: config.Get, - }, - } - - lock := config.MonitorsLock{ - "existing-monitor": { - ID: 1, - Monitor: existingMonitor, - }, - "to-update-monitor": { - ID: 2, - Monitor: toUpdateMonitor, - }, - "to-delete-monitor": { - ID: 3, - Monitor: toDeleteMonitor, - }, - } - - updatedMonitor := config.Monitor{ - Name: "To Update Monitor - Updated", - Active: false, - Frequency: config.The5M, - Kind: config.HTTP, - Regions: []config.Region{config.Iad, config.Ams}, - Request: config.Request{ - URL: "https://update.example.com", - Method: config.Post, - }, - } - - newMonitor := config.Monitor{ - Name: "New Monitor", - Active: true, - Frequency: config.The1M, - Kind: config.HTTP, - Regions: []config.Region{config.Syd}, - Request: config.Request{ - URL: "https://new.example.com", - Method: config.Get, - }, - } - - configData := config.Monitors{ - "existing-monitor": existingMonitor, - "to-update-monitor": updatedMonitor, - "new-monitor": newMonitor, - } - - var bf bytes.Buffer - log.SetOutput(&bf) - t.Cleanup(func() { - log.SetOutput(os.Stdout) - }) - - // Setup stdin to decline the confirmation - cleanup := setupStdinWithInput(t, "n\n") - defer cleanup() - - result, err := monitors.CompareLockWithConfig("test-api-key", false, lock, configData) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Result should be nil because user declined - if result != nil { - t.Errorf("Expected nil result when user declines, got %v", result) - } - }) } diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index a6677e8..9449739 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -10,32 +10,33 @@ import ( monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" - confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" ) // CreateMonitor creates a monitor using the SDK, dispatching to the appropriate type -func CreateMonitor(httpClient *http.Client, apiKey string, monitor config.Monitor) (Monitor, error) { +func CreateMonitor(ctx context.Context, httpClient *http.Client, apiKey string, monitor config.Monitor) (Monitor, error) { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) switch monitor.Kind { case config.HTTP: - return CreateHTTPMonitor(client, monitor) + return CreateHTTPMonitor(ctx, client, monitor) case config.TCP: - return CreateTCPMonitor(client, monitor) + return CreateTCPMonitor(ctx, client, monitor) default: return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) } } // CreateHTTPMonitor creates an HTTP monitor using the SDK -func CreateHTTPMonitor(client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { +func CreateHTTPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { req := &monitorv1.CreateHTTPMonitorRequest{ Monitor: configToHTTPMonitor(monitor), } - resp, err := client.CreateHTTPMonitor(context.Background(), req) + resp, err := client.CreateHTTPMonitor(ctx, req) if err != nil { return Monitor{}, fmt.Errorf("failed to create HTTP monitor: %w", err) } @@ -44,12 +45,12 @@ func CreateHTTPMonitor(client monitorv1connect.MonitorServiceClient, monitor con } // CreateTCPMonitor creates a TCP monitor using the SDK -func CreateTCPMonitor(client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { +func CreateTCPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { req := &monitorv1.CreateTCPMonitorRequest{ Monitor: configToTCPMonitor(monitor), } - resp, err := client.CreateTCPMonitor(context.Background(), req) + resp, err := client.CreateTCPMonitor(ctx, req) if err != nil { return Monitor{}, fmt.Errorf("failed to create TCP monitor: %w", err) } @@ -95,16 +96,21 @@ func tcpMonitorToLocal(m *monitorv1.TCPMonitor) Monitor { } func GetMonitorCreateCmd() *cli.Command { - monitorInfoCmd := cli.Command{ + monitorCreateCmd := cli.Command{ Name: "create", - Usage: "Create monitors (beta)", + Usage: "Create monitors", Hidden: true, HideHelp: true, HideHelpCommand: true, - Description: "Create the monitors defined in the openstatus.yaml file", - UsageText: "openstatus monitors create [options]", + Description: "Create the monitors defined in the openstatus.yaml file", + UsageText: `openstatus monitors create + openstatus monitors create --config custom.yaml -y`, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } path := cmd.String("config") @@ -122,7 +128,7 @@ func GetMonitorCreateCmd() *cli.Command { } if !accept { - confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to create %d monitors do you want to continue", len(monitors))) + confirmed, err := output.AskForConfirmation(fmt.Sprintf("You are about to create %d monitors do you want to continue", len(monitors))) if err != nil { return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) } @@ -130,13 +136,17 @@ func GetMonitorCreateCmd() *cli.Command { return nil } } + s := output.StartSpinner("Creating monitors...") for _, value := range monitors { - _, err = CreateMonitor(http.DefaultClient, cmd.String("access-token"), value) + _, err = CreateMonitor(ctx, http.DefaultClient, apiKey, value) if err != nil { + output.StopSpinner(s) return cli.Exit("Unable to create monitor", 1) } } + output.StopSpinner(s) fmt.Printf("%d monitors created successfully\n", len(monitors)) + fmt.Println("Run 'openstatus monitors list' to see all monitors") return nil }, Flags: []cli.Flag{ @@ -148,11 +158,10 @@ func GetMonitorCreateCmd() *cli.Command { Value: "openstatus.yaml", }, &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.BoolFlag{ Name: "auto-accept", @@ -162,5 +171,5 @@ func GetMonitorCreateCmd() *cli.Command { }, }, } - return &monitorInfoCmd + return &monitorCreateCmd } diff --git a/internal/monitors/monitor_create_test.go b/internal/monitors/monitor_create_test.go index c840fa7..5f70221 100644 --- a/internal/monitors/monitor_create_test.go +++ b/internal/monitors/monitor_create_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -44,7 +45,7 @@ func Test_CreateMonitor(t *testing.T) { }, } - result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + result, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -87,7 +88,7 @@ func Test_CreateMonitor(t *testing.T) { }, } - result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + result, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -122,7 +123,7 @@ func Test_CreateMonitor(t *testing.T) { Kind: config.HTTP, } - _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + _, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err == nil { t.Error("Expected error for non-200 status, got nil") } @@ -148,7 +149,7 @@ func Test_CreateMonitor(t *testing.T) { Kind: config.HTTP, } - result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + result, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -204,7 +205,7 @@ func Test_CreateMonitor(t *testing.T) { Kind: "unsupported", } - _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + _, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err == nil { t.Error("Expected error for unsupported monitor kind, got nil") } @@ -248,7 +249,7 @@ func Test_CreateMonitor_FollowRedirects(t *testing.T) { }, } - _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + _, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -297,7 +298,7 @@ func Test_CreateMonitor_FollowRedirects(t *testing.T) { }, } - _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + _, err := monitors.CreateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/monitors/monitor_delete.go b/internal/monitors/monitor_delete.go index d99cdc7..c9b47a9 100644 --- a/internal/monitors/monitor_delete.go +++ b/internal/monitors/monitor_delete.go @@ -4,33 +4,38 @@ import ( "context" "fmt" "net/http" + "os" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" - confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) // DeleteMonitor deletes a monitor using the SDK -func DeleteMonitor(client monitorv1connect.MonitorServiceClient, monitorId string) error { +func DeleteMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, monitorId string) error { if monitorId == "" { - return fmt.Errorf("Monitor ID is required") + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors delete ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors delete 12345") + return fmt.Errorf("monitor ID is required") } - _, err := client.DeleteMonitor(context.Background(), &monitorv1.DeleteMonitorRequest{ + _, err := client.DeleteMonitor(ctx, &monitorv1.DeleteMonitorRequest{ Id: monitorId, }) if err != nil { - return fmt.Errorf("failed to delete monitor: %w", err) + return output.FormatError(err, "monitor", monitorId) } return nil } // DeleteMonitorWithHTTPClient is a convenience function that creates a client and deletes a monitor -func DeleteMonitorWithHTTPClient(httpClient *http.Client, apiKey string, monitorId string) error { +func DeleteMonitorWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string) error { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - return DeleteMonitor(client, monitorId) + return DeleteMonitor(ctx, client, monitorId) } func GetMonitorDeleteCmd() *cli.Command { @@ -40,15 +45,15 @@ func GetMonitorDeleteCmd() *cli.Command { Hidden: true, HideHelpCommand: true, HideHelp: true, - UsageText: "openstatus monitors delete [MonitorID] [options]", + UsageText: `openstatus monitors delete + openstatus monitors delete 12345 -y`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.BoolFlag{ Name: "auto-accept", @@ -58,10 +63,14 @@ func GetMonitorDeleteCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } monitorId := cmd.Args().Get(0) if !cmd.Bool("auto-accept") { - confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete monitor: %s, do you want to continue", monitorId)) + confirmed, err := output.AskForConfirmation(fmt.Sprintf("You are about to delete monitor: %s, do you want to continue", monitorId)) if err != nil { return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) } @@ -69,12 +78,15 @@ func GetMonitorDeleteCmd() *cli.Command { return nil } } - client := NewMonitorClient(cmd.String("access-token")) - err := DeleteMonitor(client, monitorId) + client := NewMonitorClient(apiKey) + s := output.StartSpinner("Deleting monitor...") + err = DeleteMonitor(ctx, client, monitorId) + output.StopSpinner(s) if err != nil { - return cli.Exit("Failed to delete monitor", 1) + return cli.Exit(err.Error(), 1) } fmt.Printf("Monitor deleted successfully\n") + fmt.Println("Run 'openstatus monitors list' to see remaining monitors") return nil }, } diff --git a/internal/monitors/monitor_delete_test.go b/internal/monitors/monitor_delete_test.go index 4ec0884..0af0d52 100644 --- a/internal/monitors/monitor_delete_test.go +++ b/internal/monitors/monitor_delete_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -24,12 +25,12 @@ func Test_DeleteMonitor(t *testing.T) { }, } - err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "") + err := monitors.DeleteMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-api-key", "") if err == nil { t.Error("Expected error for empty monitor ID, got nil") } - if err.Error() != "Monitor ID is required" { - t.Errorf("Expected 'Monitor ID is required' error, got %v", err) + if err.Error() != "monitor ID is required" { + t.Errorf("Expected 'monitor ID is required' error, got %v", err) } }) @@ -55,7 +56,7 @@ func Test_DeleteMonitor(t *testing.T) { }, } - err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "123") + err := monitors.DeleteMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-api-key", "123") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -77,7 +78,7 @@ func Test_DeleteMonitor(t *testing.T) { }, } - err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "999") + err := monitors.DeleteMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-api-key", "999") if err == nil { t.Error("Expected error for non-200 status, got nil") } diff --git a/internal/monitors/monitor_import.go b/internal/monitors/monitor_import.go index 73c08bb..ff68b71 100644 --- a/internal/monitors/monitor_import.go +++ b/internal/monitors/monitor_import.go @@ -3,21 +3,23 @@ package monitors import ( "context" "fmt" + "net" "net/http" "os" "strconv" - "strings" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" "sigs.k8s.io/yaml" ) // ExportMonitor exports all monitors to a YAML file using the SDK -func ExportMonitor(client monitorv1connect.MonitorServiceClient, path string) error { - resp, err := client.ListMonitors(context.Background(), &monitorv1.ListMonitorsRequest{}) +func ExportMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, path string) error { + resp, err := client.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) if err != nil { return fmt.Errorf("failed to list monitors: %w", err) } @@ -73,18 +75,18 @@ func ExportMonitor(client monitorv1connect.MonitorServiceClient, path string) er lockFile, err := os.OpenFile("openstatus.lock", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - return cli.Exit("Failed to apply change", 1) + return fmt.Errorf("failed to create lock file: %w", err) } defer lockFile.Close() y, err = yaml.Marshal(&lock) if err != nil { - return cli.Exit("Failed to apply change", 1) + return fmt.Errorf("failed to marshal lock file: %w", err) } _, err = lockFile.Write(y) if err != nil { - return cli.Exit("Failed to apply change", 1) + return fmt.Errorf("failed to write lock file: %w", err) } return nil @@ -156,13 +158,15 @@ func convertTCPMonitorToConfig(m *monitorv1.TCPMonitor) config.Monitor { regions[i] = config.Region(regionToString(r)) } - // Parse host:port from URI + // Parse host:port from URI using net.SplitHostPort for IPv6 support uri := m.GetUri() - parts := strings.Split(uri, ":") - host := parts[0] + host, portStr, err := net.SplitHostPort(uri) var port int64 - if len(parts) > 1 { - p, _ := strconv.Atoi(parts[1]) + if err != nil { + // Fallback: treat the whole URI as host if no port separator found + host = uri + } else { + p, _ := strconv.Atoi(portStr) port = int64(p) } @@ -275,33 +279,40 @@ func convertStringComparator(c monitorv1.StringComparator) config.Compare { } // ExportMonitorWithHTTPClient is a convenience function that creates a client and exports monitors -func ExportMonitorWithHTTPClient(httpClient *http.Client, apiKey string, path string) error { +func ExportMonitorWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, path string) error { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - return ExportMonitor(client, path) + return ExportMonitor(ctx, client, path) } func GetMonitorImportCmd() *cli.Command { - monitorInfoCmd := cli.Command{ + monitorImportCmd := cli.Command{ Name: "import", Usage: "Import all your monitors", - UsageText: "openstatus monitors import [options]", + UsageText: `openstatus monitors import + openstatus monitors import --output monitors.yaml`, Description: "Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with 'apply'.", Action: func(ctx context.Context, cmd *cli.Command) error { - client := NewMonitorClient(cmd.String("access-token")) - err := ExportMonitor(client, cmd.String("output")) + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + s := output.StartSpinner("Importing monitors...") + client := NewMonitorClient(apiKey) + err = ExportMonitor(ctx, client, cmd.String("output")) + output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) } - fmt.Printf("Monitors successfully imported to: %s", cmd.String("output")) + fmt.Printf("Monitors successfully imported to: %s\n", cmd.String("output")) + fmt.Println("Run 'openstatus monitors apply' to sync changes") return nil }, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ Name: "output", @@ -312,5 +323,5 @@ func GetMonitorImportCmd() *cli.Command { }, }, } - return &monitorInfoCmd + return &monitorImportCmd } diff --git a/internal/monitors/monitor_import_test.go b/internal/monitors/monitor_import_test.go index b7761ba..00c8f47 100644 --- a/internal/monitors/monitor_import_test.go +++ b/internal/monitors/monitor_import_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "io" "net/http" "os" @@ -41,7 +42,7 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -80,7 +81,7 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -102,7 +103,7 @@ func Test_ExportMonitor(t *testing.T) { }, } - err := monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "invalid-key", "output.yaml") + err := monitors.ExportMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"invalid-key", "output.yaml") if err == nil { t.Error("Expected error for non-200 status, got nil") } @@ -132,7 +133,7 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index d909960..7844f32 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -8,6 +8,8 @@ import ( "strings" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/logrusorgru/aurora/v4" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" @@ -15,10 +17,14 @@ import ( "github.com/urfave/cli/v3" ) -func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) error { +func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, s *output.Spinner) error { if monitorId == "" { - return fmt.Errorf("Monitor ID is required") + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors info 12345") + return fmt.Errorf("monitor ID is required") } client := NewMonitorClientWithHTTPClient(httpClient, apiKey) @@ -27,9 +33,10 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er Id: monitorId, } - resp, err := client.GetMonitor(context.Background(), req) + resp, err := client.GetMonitor(ctx, req) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("failed to get monitor: %w", err) + return output.FormatError(err, "monitor", monitorId) } monitorConfig := resp.GetMonitor() @@ -46,6 +53,10 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("unknown monitor type") } + if output.IsJSONOutput() { + return output.PrintJSON(monitor) + } + fmt.Println(aurora.Bold("Monitor:")) table := tablewriter.NewTable(os.Stdout, tablewriter.WithRenderer(renderer.NewBlueprint()), @@ -58,18 +69,18 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er Bottom: tw.Off, }, Settings: tw.Settings{ - Lines: tw.Lines{ // Major internal separator lines - ShowHeaderLine: tw.Off, // Line after header - ShowFooterLine: tw.On, // Line before footer (if footer exists) + Lines: tw.Lines{ + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.On, }, - Separators: tw.Separators{ // General row and column separators - BetweenRows: tw.Off, // Horizontal lines between data rows - BetweenColumns: tw.On, // Vertical lines between columns + Separators: tw.Separators{ + BetweenRows: tw.Off, + BetweenColumns: tw.On, }, }, }), - tablewriter.WithRowAlignment(tw.AlignLeft), // Common for Markdown - tablewriter.WithHeaderAlignment(tw.AlignLeft), // + tablewriter.WithRowAlignment(tw.AlignLeft), + tablewriter.WithHeaderAlignment(tw.AlignLeft), ) data := [][]string{ @@ -84,7 +95,6 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er data = append(data, []string{"Frequency", monitor.Periodicity}) - // Group regions by provider and display each provider on its own row regionGroups := groupRegionsByProvider(regions) providers := []string{"Fly.io", "Koyeb", "Railway"} for _, provider := range providers { @@ -105,8 +115,11 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er } if monitor.Body != "" { - s := fmt.Sprintf("%s", monitor.Body) - data = append(data, []string{"Body", s[:40]}) + s := monitor.Body + if len(s) > 40 { + s = s[:40] + } + data = append(data, []string{"Body", s}) } table.Bulk(data) table.Render() @@ -116,13 +129,19 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er func GetMonitorInfoCmd() *cli.Command { monitorInfoCmd := cli.Command{ - Name: "info", - Usage: "Get a monitor information", - UsageText: "openstatus monitors info [MonitorID]", - Description: "Fetch the monitor information. The monitor information includes details such as name, description, endpoint, method, frequency, locations, active status, public status, timeout, degraded after, and body. The body is truncated to 40 characters.", + Name: "info", + Usage: "Get a monitor information", + UsageText: `openstatus monitors info + openstatus monitors info 12345`, + Description: "Fetch the monitor information. The monitor information includes details such as name, description, endpoint, method, frequency, locations, active status, public status, timeout, degraded after, and body.", Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } monitorId := cmd.Args().Get(0) - err := GetMonitorInfo(http.DefaultClient, cmd.String("access-token"), monitorId) + s := output.StartSpinner("Fetching monitor details...") + err = GetMonitorInfo(ctx, http.DefaultClient, apiKey, monitorId, s) if err != nil { return cli.Exit(err.Error(), 1) } @@ -130,11 +149,10 @@ func GetMonitorInfoCmd() *cli.Command { }, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }}, } return &monitorInfoCmd diff --git a/internal/monitors/monitor_info_test.go b/internal/monitors/monitor_info_test.go index 54ac988..e4522aa 100644 --- a/internal/monitors/monitor_info_test.go +++ b/internal/monitors/monitor_info_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "io" "log" "net/http" @@ -29,7 +30,7 @@ func Test_getMonitorInfo(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.GetMonitorInfo(interceptor.GetHTTPClient(), "", "") + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "", "", nil) if err == nil { t.Errorf("Expected log output, got nothing") } @@ -55,7 +56,7 @@ func Test_getMonitorInfo(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.GetMonitorInfo(interceptor.GetHTTPClient(), "test", "1") + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "1", nil) if err != nil { t.Errorf("Expected no error, got %v", err) } diff --git a/internal/monitors/monitor_trigger.go b/internal/monitors/monitor_trigger.go index d344d5e..85e2eee 100644 --- a/internal/monitors/monitor_trigger.go +++ b/internal/monitors/monitor_trigger.go @@ -4,24 +4,31 @@ import ( "context" "fmt" "net/http" + "os" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) // TriggerMonitor triggers a monitor using the SDK -func TriggerMonitor(client monitorv1connect.MonitorServiceClient, monitorId string) error { +func TriggerMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, monitorId string, s *output.Spinner) error { if monitorId == "" { - return fmt.Errorf("Monitor ID is required") + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors trigger ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors trigger 12345") + return fmt.Errorf("monitor ID is required") } - fmt.Println("Waiting for the result...") - _, err := client.TriggerMonitor(context.Background(), &monitorv1.TriggerMonitorRequest{ + _, err := client.TriggerMonitor(ctx, &monitorv1.TriggerMonitorRequest{ Id: monitorId, }) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("failed to trigger monitor: %w", err) + return output.FormatError(err, "monitor", monitorId) } fmt.Printf("Check triggered successfully\n") @@ -29,32 +36,37 @@ func TriggerMonitor(client monitorv1connect.MonitorServiceClient, monitorId stri } // TriggerMonitorWithHTTPClient is a convenience function that creates a client and triggers a monitor -func TriggerMonitorWithHTTPClient(httpClient *http.Client, apiKey string, monitorId string) error { +func TriggerMonitorWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string) error { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - return TriggerMonitor(client, monitorId) + return TriggerMonitor(ctx, client, monitorId, nil) } func GetMonitorsTriggerCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "trigger", Usage: "Trigger a monitor execution", - UsageText: "openstatus monitors trigger [MonitorId] [options]", + UsageText: `openstatus monitors trigger + openstatus monitors trigger 12345`, Description: "Trigger a monitor execution on demand. This command allows you to launch your tests on demand.", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } monitorId := cmd.Args().Get(0) - client := NewMonitorClient(cmd.String("access-token")) - err := TriggerMonitor(client, monitorId) + s := output.StartSpinner("Triggering monitor...") + client := NewMonitorClient(apiKey) + err = TriggerMonitor(ctx, client, monitorId, s) if err != nil { - return cli.Exit("Failed to trigger monitor", 1) + return cli.Exit(err.Error(), 1) } return nil }, diff --git a/internal/monitors/monitor_trigger_test.go b/internal/monitors/monitor_trigger_test.go index cdd10c9..2f7f596 100644 --- a/internal/monitors/monitor_trigger_test.go +++ b/internal/monitors/monitor_trigger_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "io" "log" "net/http" @@ -31,7 +32,7 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "", "") + err := monitors.TriggerMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "", "") if err == nil { t.Errorf("Expected error for empty monitor ID, got nil") } @@ -58,7 +59,7 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") + err := monitors.TriggerMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1") if err != nil { t.Errorf("Expected no error, got: %v", err) } @@ -84,7 +85,7 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") + err := monitors.TriggerMonitorWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1") if err == nil { t.Errorf("Expected error for non-200 status, got nil") } diff --git a/internal/monitors/monitor_update.go b/internal/monitors/monitor_update.go index e99d3ae..d355516 100644 --- a/internal/monitors/monitor_update.go +++ b/internal/monitors/monitor_update.go @@ -12,21 +12,21 @@ import ( ) // UpdateMonitor updates a monitor using the SDK, dispatching to the appropriate type -func UpdateMonitor(httpClient *http.Client, apiKey string, id int, monitor config.Monitor) (Monitor, error) { +func UpdateMonitor(ctx context.Context, httpClient *http.Client, apiKey string, id int, monitor config.Monitor) (Monitor, error) { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) switch monitor.Kind { case config.HTTP: - return UpdateHTTPMonitor(client, id, monitor) + return UpdateHTTPMonitor(ctx, client, id, monitor) case config.TCP: - return UpdateTCPMonitor(client, id, monitor) + return UpdateTCPMonitor(ctx, client, id, monitor) default: return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) } } // UpdateHTTPMonitor updates an HTTP monitor using the SDK -func UpdateHTTPMonitor(client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { +func UpdateHTTPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { httpMonitor := configToHTTPMonitor(monitor) httpMonitor.Id = strconv.Itoa(id) @@ -35,7 +35,7 @@ func UpdateHTTPMonitor(client monitorv1connect.MonitorServiceClient, id int, mon Monitor: httpMonitor, } - resp, err := client.UpdateHTTPMonitor(context.Background(), req) + resp, err := client.UpdateHTTPMonitor(ctx, req) if err != nil { return Monitor{}, fmt.Errorf("failed to update HTTP monitor: %w", err) } @@ -44,7 +44,7 @@ func UpdateHTTPMonitor(client monitorv1connect.MonitorServiceClient, id int, mon } // UpdateTCPMonitor updates a TCP monitor using the SDK -func UpdateTCPMonitor(client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { +func UpdateTCPMonitor(ctx context.Context, client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { tcpMonitor := configToTCPMonitor(monitor) tcpMonitor.Id = strconv.Itoa(id) @@ -53,7 +53,7 @@ func UpdateTCPMonitor(client monitorv1connect.MonitorServiceClient, id int, moni Monitor: tcpMonitor, } - resp, err := client.UpdateTCPMonitor(context.Background(), req) + resp, err := client.UpdateTCPMonitor(ctx, req) if err != nil { return Monitor{}, fmt.Errorf("failed to update TCP monitor: %w", err) } diff --git a/internal/monitors/monitor_update_test.go b/internal/monitors/monitor_update_test.go index 210b053..be969fe 100644 --- a/internal/monitors/monitor_update_test.go +++ b/internal/monitors/monitor_update_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -43,7 +44,7 @@ func Test_UpdateMonitor(t *testing.T) { }, } - result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + result, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 123, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -82,7 +83,7 @@ func Test_UpdateMonitor(t *testing.T) { }, } - result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 456, monitor) + result, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 456, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -114,7 +115,7 @@ func Test_UpdateMonitor(t *testing.T) { Kind: config.HTTP, } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 999, monitor) + _, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 999, monitor) if err == nil { t.Error("Expected error for not found status, got nil") } @@ -136,7 +137,7 @@ func Test_UpdateMonitor(t *testing.T) { Kind: "unsupported", } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + _, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 123, monitor) if err == nil { t.Error("Expected error for unsupported monitor kind, got nil") } @@ -161,7 +162,7 @@ func Test_UpdateMonitor(t *testing.T) { Kind: config.HTTP, } - result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 789, monitor) + result, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 789, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -235,7 +236,7 @@ func Test_UpdateMonitor_FollowRedirects(t *testing.T) { }, } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + _, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 123, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -284,7 +285,7 @@ func Test_UpdateMonitor_FollowRedirects(t *testing.T) { }, } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 124, monitor) + _, err := monitors.UpdateMonitor(context.Background(), interceptor.GetHTTPClient(), "test-api-key", 124, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index eb1907d..a13adee 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -500,7 +500,7 @@ func MonitorsCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "monitors", Usage: "Manage your monitors", - + Aliases: []string{"m"}, Commands: []*cli.Command{ GetMonitorsApplyCmd(), GetMonitorCreateCmd(), diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index 849d4d0..f0d75e8 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -7,84 +7,119 @@ import ( monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/fatih/color" "github.com/rodaine/table" "github.com/urfave/cli/v3" ) -var allMonitor bool +type monitorListEntry struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Kind string `json:"kind"` +} -// ListMonitors fetches and displays all monitors using the SDK -func ListMonitors(client monitorv1connect.MonitorServiceClient) error { - resp, err := client.ListMonitors(context.Background(), &monitorv1.ListMonitorsRequest{}) +func ListMonitors(ctx context.Context, client monitorv1connect.MonitorServiceClient, showAll bool, s *output.Spinner) error { + resp, err := client.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("failed to list monitors: %w", err) + return output.FormatError(err, "monitors", "") } - headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() - columnFmt := color.New(color.FgYellow).SprintfFunc() - - tbl := table.New("ID", "Name", "Url") - tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + var entries []monitorListEntry - // Add HTTP monitors for _, monitor := range resp.GetHttpMonitors() { - if monitor.GetActive() || allMonitor { - tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUrl()) + if monitor.GetActive() || showAll { + entries = append(entries, monitorListEntry{ + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUrl(), + Kind: "http", + }) } } - // Add TCP monitors for _, monitor := range resp.GetTcpMonitors() { - if monitor.GetActive() || allMonitor { - tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUri()) + if monitor.GetActive() || showAll { + entries = append(entries, monitorListEntry{ + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUri(), + Kind: "tcp", + }) } } - // Add DNS monitors for _, monitor := range resp.GetDnsMonitors() { - if monitor.GetActive() || allMonitor { - tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUri()) + if monitor.GetActive() || showAll { + entries = append(entries, monitorListEntry{ + ID: monitor.GetId(), + Name: monitor.GetName(), + URL: monitor.GetUri(), + Kind: "dns", + }) } } + if output.IsJSONOutput() { + return output.PrintJSON(entries) + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Name", "Url", "Kind") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, e := range entries { + tbl.AddRow(e.ID, e.Name, e.URL, e.Kind) + } + tbl.Print() return nil } -// ListMonitorsWithHTTPClient is a convenience function that creates a client and lists monitors -func ListMonitorsWithHTTPClient(httpClient *http.Client, apiKey string) error { +func ListMonitorsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string) error { client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - return ListMonitors(client) + return ListMonitors(ctx, client, false, nil) } func GetMonitorsListCmd() *cli.Command { monitorsListCmd := cli.Command{ - Name: "list", - Usage: "List all monitors", - Description: "List all monitors. The list shows all your monitors attached to your workspace. It displays the ID, name, and URL of each monitor.", - UsageText: "openstatus monitors list [options]", + Name: "list", + Usage: "List all monitors", + Description: `List all monitors. The list shows all your monitors attached to your workspace. +It displays the ID, name, URL, and kind of each monitor.`, + UsageText: `openstatus monitors list + openstatus monitors list --all`, Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "all", - Usage: "List all monitors including inactive ones", - Destination: &allMonitor, + Name: "all", + Usage: "List all monitors including inactive ones", }, &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - fmt.Println("List of all monitors") - client := NewMonitorClient(cmd.String("access-token")) - err := ListMonitors(client) + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + if !output.IsQuiet() && !output.IsJSONOutput() { + fmt.Println("List of all monitors") + } + s := output.StartSpinner("Fetching monitors...") + client := NewMonitorClient(apiKey) + err = ListMonitors(ctx, client, cmd.Bool("all"), s) if err != nil { - return cli.Exit("Failed to list monitors", 1) + return cli.Exit(err.Error(), 1) } return nil }, diff --git a/internal/monitors/monitors_list_test.go b/internal/monitors/monitors_list_test.go index 6399b9b..9dbc2c9 100644 --- a/internal/monitors/monitors_list_test.go +++ b/internal/monitors/monitors_list_test.go @@ -2,6 +2,7 @@ package monitors_test import ( "bytes" + "context" "io" "log" "net/http" @@ -37,7 +38,7 @@ func Test_listMonitors(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.ListMonitorsWithHTTPClient(interceptor.GetHTTPClient(), "test-token") + err := monitors.ListMonitorsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token") if err != nil { t.Error(err) t.Errorf("Expected log output, got nothing") @@ -64,7 +65,7 @@ func Test_listMonitors(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.ListMonitorsWithHTTPClient(interceptor.GetHTTPClient(), "1") + err := monitors.ListMonitorsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "1") if err == nil { t.Errorf("Expected error, got nothing") } diff --git a/internal/run/run.go b/internal/run/run.go index e5e74ed..e6af663 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -15,116 +15,150 @@ import ( "github.com/fatih/color" "github.com/logrusorgru/aurora/v4" "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" "github.com/rodaine/table" "github.com/urfave/cli/v3" ) -func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) error { +type runRegionResult struct { + Region string `json:"region"` + Latency int64 `json:"latency"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type runMonitorResult struct { + MonitorID string `json:"monitor_id"` + Results []runRegionResult `json:"results"` +} + +// MonitorTrigger triggers a monitor run and returns the results without printing. +func MonitorTrigger(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string) (runMonitorResult, error) { if monitorId == "" { - return fmt.Errorf("Monitor ID is required") + return runMonitorResult{}, fmt.Errorf("monitor ID is required") } url := fmt.Sprintf("%s/monitor/%s/run", api.APIBaseURL, monitorId) - httpClient.Timeout = 2 * time.Minute + client := &http.Client{ + Timeout: 2 * time.Minute, + Transport: httpClient.Transport, + } payload := strings.NewReader("{}") - req, err := http.NewRequest("POST", url, payload) + req, err := http.NewRequestWithContext(ctx, "POST", url, payload) if err != nil { - return err + return runMonitorResult{}, err } req.Header.Add("x-openstatus-key", apiKey) - res, err := httpClient.Do(req) + res, err := client.Do(req) if err != nil { - return err + return runMonitorResult{}, err } + defer res.Body.Close() if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to trigger monitor test") + return runMonitorResult{}, fmt.Errorf("failed to trigger monitor test") } - - defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return runMonitorResult{}, fmt.Errorf("failed to read response body: %w", err) } var result []json.RawMessage err = json.Unmarshal(body, &result) if err != nil { - return err + return runMonitorResult{}, err } - fmt.Println(aurora.Bold(fmt.Sprintf("Monitor: %s", monitorId))) - headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() - columnFmt := color.New(color.FgYellow).SprintfFunc() - - tbl := table.New("Region", "Latency (ms)", "Status") - tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) - var inError bool + var regionResults []runRegionResult for _, r := range result { - result := monitors.RunResult{} + rr := monitors.RunResult{} - if err := json.Unmarshal(r, &result); err != nil { + if err := json.Unmarshal(r, &rr); err != nil { + return runMonitorResult{}, fmt.Errorf("unable to unmarshal: %w", err) + } - return fmt.Errorf("unable to unmarshal : %w", err) + entry := runRegionResult{ + Region: rr.Region, + Latency: rr.Latency, + Status: "pass", } - switch result.JobType { - case "tcp": - { - var tcp monitors.TCPRunResult - if err := json.Unmarshal(r, &result); err != nil { - return fmt.Errorf("unable to unmarshal : %w", err) - } - if tcp.ErrorMessage != "" { - inError = true - tbl.AddRow(result.Region, result.Latency, color.RedString("❌")) - continue - } + switch rr.JobType { + case "tcp": + var tcp monitors.TCPRunResult + if err := json.Unmarshal(r, &tcp); err != nil { + return runMonitorResult{}, fmt.Errorf("unable to unmarshal: %w", err) + } + if tcp.ErrorMessage != "" { + entry.Status = "fail" + entry.Error = tcp.ErrorMessage } case "http": - { - var http monitors.HTTPRunResult - if err := json.Unmarshal(r, &http); err != nil { - fmt.Println("Error", err) - return fmt.Errorf("unable to unmarshal : %w", err) - } - if http.Error != "" { - inError = true - tbl.AddRow(result.Region, result.Latency, color.RedString("❌")) - continue - } + var httpResult monitors.HTTPRunResult + if err := json.Unmarshal(r, &httpResult); err != nil { + return runMonitorResult{}, fmt.Errorf("unable to unmarshal: %w", err) + } + if httpResult.Error != "" { + entry.Status = "fail" + entry.Error = httpResult.Error } default: - return fmt.Errorf("Unknown job type") + return runMonitorResult{}, fmt.Errorf("unknown job type") } - tbl.AddRow(result.Region, result.Latency, color.GreenString("✔")) + regionResults = append(regionResults, entry) + } + + return runMonitorResult{ + MonitorID: monitorId, + Results: regionResults, + }, nil +} +// printMonitorResult prints a single monitor's results to stdout. +func printMonitorResult(res runMonitorResult) bool { + var inError bool + fmt.Println(aurora.Bold(fmt.Sprintf("Monitor: %s", res.MonitorID))) + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Region", "Latency (ms)", "Status") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, entry := range res.Results { + if entry.Status == "fail" { + tbl.AddRow(entry.Region, entry.Latency, color.RedString("fail")) + inError = true + } else { + tbl.AddRow(entry.Region, entry.Latency, color.GreenString("pass")) + } } tbl.Print() if inError { fmt.Println(color.RedString("Some regions failed")) - return fmt.Errorf("Some regions failed") } else { fmt.Println(color.GreenString("All regions passed")) } - return nil + fmt.Println() + return inError } func RunCmd() *cli.Command { runCmd := cli.Command{ - Name: "run", - Aliases: []string{"r"}, - Usage: "Run your synthetics tests", - UsageText: "openstatus run [options]", - Description: `Run the synthetic tests defined in the config.openstatus.yaml. + Name: "run", + Aliases: []string{"r"}, + Usage: "Run your uptime tests", + UsageText: `openstatus run + openstatus run --config custom-config.yaml`, + Description: `Run the uptime tests defined in the config.openstatus.yaml. The config file should be in the following format: tests: @@ -134,6 +168,10 @@ tests: `, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } path := cmd.String("config") if path != "" { @@ -147,26 +185,62 @@ tests: return err } size := len(conf.Tests.Ids) - ch := make(chan error, size) - fmt.Print("Tests are running\n\n") + if !output.IsQuiet() && !output.IsJSONOutput() { + fmt.Print("Tests are running\n\n") + } + type indexedResult struct { + index int + result runMonitorResult + err error + } + + results := make([]indexedResult, size) var wg sync.WaitGroup + var mu sync.Mutex - for _, id := range conf.Tests.Ids { + for i, id := range conf.Tests.Ids { wg.Add(1) - go func(id int) { + go func(idx, id int) { defer wg.Done() - if err := MonitorTrigger(http.DefaultClient, cmd.String("access-token"), fmt.Sprintf("%d", id)); err != nil { - ch <- err - } - - }(id) + res, err := MonitorTrigger(ctx, http.DefaultClient, apiKey, fmt.Sprintf("%d", id)) + mu.Lock() + results[idx] = indexedResult{index: idx, result: res, err: err} + mu.Unlock() + }(i, id) } wg.Wait() - close(ch) // Close the channel when all workers have finished - if len(ch) > 0 { + // Print results sequentially to avoid interleaved output + var hasErrors bool + + if output.IsJSONOutput() { + var allResults []runMonitorResult + for _, r := range results { + if r.err != nil { + hasErrors = true + continue + } + allResults = append(allResults, r.result) + } + if err := output.PrintJSON(allResults); err != nil { + return err + } + } else { + for _, r := range results { + if r.err != nil { + hasErrors = true + fmt.Printf("Monitor %d: %v\n\n", conf.Tests.Ids[r.index], r.err) + continue + } + if printMonitorResult(r.result) { + hasErrors = true + } + } + } + + if hasErrors { return cli.Exit("Some tests failed", 1) } return nil @@ -179,11 +253,10 @@ tests: Value: "config.openstatus.yaml", }, &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, }, } diff --git a/internal/run/run_test.go b/internal/run/run_test.go index e815e94..0f5bad8 100644 --- a/internal/run/run_test.go +++ b/internal/run/run_test.go @@ -2,6 +2,7 @@ package run_test import ( "bytes" + "context" "io" "log" "net/http" @@ -50,7 +51,7 @@ func Test_run(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := run.MonitorTrigger(interceptor.GetHTTPClient(), "", "") + _, err := run.MonitorTrigger(context.Background(), interceptor.GetHTTPClient(), "", "") if err == nil { t.Error(err) t.Errorf("Monitor Trigger should return error") @@ -94,7 +95,7 @@ func Test_run(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := run.MonitorTrigger(interceptor.GetHTTPClient(), "", "1") + _, err := run.MonitorTrigger(context.Background(), interceptor.GetHTTPClient(), "", "1") if err != nil { t.Error(err) t.Errorf("Monitor Trigger should return error") @@ -130,7 +131,7 @@ func Test_run(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := run.MonitorTrigger(interceptor.GetHTTPClient(), "", "1") + _, err := run.MonitorTrigger(context.Background(), interceptor.GetHTTPClient(), "", "1") if err != nil { t.Error(err) t.Errorf("Monitor Trigger should return error") diff --git a/internal/statusreport/statusreport_add_update.go b/internal/statusreport/statusreport_add_update.go index 0e37c4b..fd3d2d6 100644 --- a/internal/statusreport/statusreport_add_update.go +++ b/internal/statusreport/statusreport_add_update.go @@ -4,15 +4,22 @@ import ( "context" "fmt" "net/http" + "os" "time" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func AddStatusReportUpdate(client status_reportv1connect.StatusReportServiceClient, reportId, status, message, date string, notify bool) error { +func AddStatusReportUpdate(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId, status, message, date string, notify bool, s *output.Spinner) error { if reportId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus status-report add-update --status --message ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus sr add-update 12345 --status resolved --message \"Fix deployed\"") return fmt.Errorf("report ID is required") } @@ -35,9 +42,10 @@ func AddStatusReportUpdate(client status_reportv1connect.StatusReportServiceClie req.SetNotify(true) } - resp, err := client.AddStatusReportUpdate(context.Background(), req) + resp, err := client.AddStatusReportUpdate(ctx, req) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("failed to add status report update: %w", err) + return output.FormatError(err, "status-report", reportId) } report := resp.GetStatusReport() @@ -50,9 +58,9 @@ func AddStatusReportUpdate(client status_reportv1connect.StatusReportServiceClie return nil } -func AddStatusReportUpdateWithHTTPClient(httpClient *http.Client, apiKey string, reportId, status, message, date string, notify bool) error { +func AddStatusReportUpdateWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId, status, message, date string, notify bool) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return AddStatusReportUpdate(client, reportId, status, message, date, notify) + return AddStatusReportUpdate(ctx, client, reportId, status, message, date, notify, nil) } func GetStatusReportAddUpdateCmd() *cli.Command { @@ -62,11 +70,10 @@ func GetStatusReportAddUpdateCmd() *cli.Command { UsageText: "openstatus status-report add-update --status resolved --message \"Issue has been resolved\"", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ Name: "status", @@ -88,6 +95,10 @@ func GetStatusReportAddUpdateCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } reportId := cmd.Args().Get(0) date := cmd.String("date") @@ -95,14 +106,17 @@ func GetStatusReportAddUpdateCmd() *cli.Command { date = time.Now().UTC().Format(time.RFC3339) } - client := NewStatusReportClient(cmd.String("access-token")) - err := AddStatusReportUpdate( + s := output.StartSpinner("Adding update...") + client := NewStatusReportClient(apiKey) + err = AddStatusReportUpdate( + ctx, client, reportId, cmd.String("status"), cmd.String("message"), date, cmd.Bool("notify"), + s, ) if err != nil { return cli.Exit(err.Error(), 1) diff --git a/internal/statusreport/statusreport_add_update_test.go b/internal/statusreport/statusreport_add_update_test.go index a4e8fc5..57e03ef 100644 --- a/internal/statusreport/statusreport_add_update_test.go +++ b/internal/statusreport/statusreport_add_update_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -29,7 +30,7 @@ func Test_AddStatusReportUpdate(t *testing.T) { } err := statusreport.AddStatusReportUpdateWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "identified", "Root cause found", "2026-03-20T10:30:00Z", false, ) if err != nil { @@ -54,7 +55,7 @@ func Test_AddStatusReportUpdate(t *testing.T) { } err := statusreport.AddStatusReportUpdateWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "resolved", "Issue resolved", "", true, ) if err != nil { @@ -75,7 +76,7 @@ func Test_AddStatusReportUpdate(t *testing.T) { } err := statusreport.AddStatusReportUpdateWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "invalid", "Message", "", false, ) if err == nil { @@ -96,7 +97,7 @@ func Test_AddStatusReportUpdate(t *testing.T) { } err := statusreport.AddStatusReportUpdateWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "", "investigating", "Message", "", false, ) if err == nil { @@ -121,7 +122,7 @@ func Test_AddStatusReportUpdate(t *testing.T) { } err := statusreport.AddStatusReportUpdateWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "999", "investigating", "Message", "", false, ) if err == nil { diff --git a/internal/statusreport/statusreport_create.go b/internal/statusreport/statusreport_create.go index 77b738a..a8facdb 100644 --- a/internal/statusreport/statusreport_create.go +++ b/internal/statusreport/statusreport_create.go @@ -9,10 +9,12 @@ import ( status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func CreateStatusReport(client status_reportv1connect.StatusReportServiceClient, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { +func CreateStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { sdkStatus, err := statusToSDK(status) if err != nil { return "", err @@ -34,17 +36,17 @@ func CreateStatusReport(client status_reportv1connect.StatusReportServiceClient, req.SetNotify(true) } - resp, err := client.CreateStatusReport(context.Background(), req) + resp, err := client.CreateStatusReport(ctx, req) if err != nil { - return "", fmt.Errorf("failed to create status report: %w", err) + return "", output.FormatError(err, "status-report", "") } return resp.GetStatusReport().GetId(), nil } -func CreateStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { +func CreateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, title, status, message, date, pageId string, componentIds []string, notify bool) (string, error) { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return CreateStatusReport(client, title, status, message, date, pageId, componentIds, notify) + return CreateStatusReport(ctx, client, title, status, message, date, pageId, componentIds, notify) } func GetStatusReportCreateCmd() *cli.Command { @@ -54,11 +56,10 @@ func GetStatusReportCreateCmd() *cli.Command { UsageText: "openstatus status-report create --title \"API Degradation\" --status investigating --message \"Investigating increased latency\" --page-id 123", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ Name: "title", @@ -94,6 +95,10 @@ func GetStatusReportCreateCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } date := cmd.String("date") if date == "" { date = time.Now().UTC().Format(time.RFC3339) @@ -104,8 +109,10 @@ func GetStatusReportCreateCmd() *cli.Command { componentIds = strings.Split(ids, ",") } - client := NewStatusReportClient(cmd.String("access-token")) + client := NewStatusReportClient(apiKey) + s := output.StartSpinner("Creating status report...") id, err := CreateStatusReport( + ctx, client, cmd.String("title"), cmd.String("status"), @@ -115,6 +122,7 @@ func GetStatusReportCreateCmd() *cli.Command { componentIds, cmd.Bool("notify"), ) + output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/statusreport/statusreport_create_test.go b/internal/statusreport/statusreport_create_test.go index 492a323..88c1c8e 100644 --- a/internal/statusreport/statusreport_create_test.go +++ b/internal/statusreport/statusreport_create_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -32,7 +33,7 @@ func Test_CreateStatusReport(t *testing.T) { } id, err := statusreport.CreateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "API Outage", "investigating", "Investigating the issue", "2026-03-20T10:00:00Z", "page-1", nil, false, ) @@ -61,7 +62,7 @@ func Test_CreateStatusReport(t *testing.T) { } id, err := statusreport.CreateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "DB Issue", "investigating", "Looking into DB issues", "2026-03-20T10:00:00Z", "page-1", []string{"c1", "c2"}, true, ) @@ -86,7 +87,7 @@ func Test_CreateStatusReport(t *testing.T) { } _, err := statusreport.CreateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "Title", "invalid-status", "Message", "2026-03-20T10:00:00Z", "page-1", nil, false, ) @@ -112,7 +113,7 @@ func Test_CreateStatusReport(t *testing.T) { } _, err := statusreport.CreateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "Title", "investigating", "Message", "2026-03-20T10:00:00Z", "page-1", nil, false, ) diff --git a/internal/statusreport/statusreport_delete.go b/internal/statusreport/statusreport_delete.go index 276c4eb..885b28f 100644 --- a/internal/statusreport/statusreport_delete.go +++ b/internal/statusreport/statusreport_delete.go @@ -4,45 +4,50 @@ import ( "context" "fmt" "net/http" + "os" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" - confirmation "github.com/openstatusHQ/cli/internal/cli" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func DeleteStatusReport(client status_reportv1connect.StatusReportServiceClient, reportId string) error { +func DeleteStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string) error { if reportId == "" { + fmt.Fprintln(os.Stderr, "Usage: openstatus status-report delete ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus status-report delete 12345") return fmt.Errorf("report ID is required") } - _, err := client.DeleteStatusReport(context.Background(), &status_reportv1.DeleteStatusReportRequest{ + _, err := client.DeleteStatusReport(ctx, &status_reportv1.DeleteStatusReportRequest{ Id: reportId, }) if err != nil { - return fmt.Errorf("failed to delete status report: %w", err) + return output.FormatError(err, "status-report", reportId) } return nil } -func DeleteStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, reportId string) error { +func DeleteStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId string) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return DeleteStatusReport(client, reportId) + return DeleteStatusReport(ctx, client, reportId) } func GetStatusReportDeleteCmd() *cli.Command { return &cli.Command{ Name: "delete", Usage: "Delete a status report", - UsageText: "openstatus status-report delete ", + UsageText: `openstatus status-report delete + openstatus status-report delete 12345 -y`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.BoolFlag{ Name: "auto-accept", @@ -51,10 +56,14 @@ func GetStatusReportDeleteCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } reportId := cmd.Args().Get(0) if !cmd.Bool("auto-accept") { - confirmed, err := confirmation.AskForConfirmation(fmt.Sprintf("You are about to delete status report: %s, do you want to continue", reportId)) + confirmed, err := output.AskForConfirmation(fmt.Sprintf("You are about to delete status report: %s, do you want to continue", reportId)) if err != nil { return cli.Exit(fmt.Sprintf("Failed to read input: %v", err), 1) } @@ -63,12 +72,15 @@ func GetStatusReportDeleteCmd() *cli.Command { } } - client := NewStatusReportClient(cmd.String("access-token")) - err := DeleteStatusReport(client, reportId) + client := NewStatusReportClient(apiKey) + s := output.StartSpinner("Deleting status report...") + err = DeleteStatusReport(ctx, client, reportId) + output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) } fmt.Printf("Status report %s deleted successfully\n", reportId) + fmt.Println("Run 'openstatus status-report list' to see remaining reports") return nil }, } diff --git a/internal/statusreport/statusreport_delete_test.go b/internal/statusreport/statusreport_delete_test.go index cbd7d6d..ca0c1f2 100644 --- a/internal/statusreport/statusreport_delete_test.go +++ b/internal/statusreport/statusreport_delete_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -24,7 +25,7 @@ func Test_DeleteStatusReport(t *testing.T) { }, } - err := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "") + err := statusreport.DeleteStatusReportWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "") if err == nil { t.Error("Expected error for empty report ID, got nil") } @@ -55,7 +56,7 @@ func Test_DeleteStatusReport(t *testing.T) { }, } - err := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "123") + err := statusreport.DeleteStatusReportWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "123") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -77,7 +78,7 @@ func Test_DeleteStatusReport(t *testing.T) { }, } - err := statusreport.DeleteStatusReportWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "999") + err := statusreport.DeleteStatusReportWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "999") if err == nil { t.Error("Expected error for not found, got nil") } diff --git a/internal/statusreport/statusreport_info.go b/internal/statusreport/statusreport_info.go index 56f0f44..03b526e 100644 --- a/internal/statusreport/statusreport_info.go +++ b/internal/statusreport/statusreport_info.go @@ -9,6 +9,8 @@ import ( status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/logrusorgru/aurora/v4" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" @@ -16,20 +18,60 @@ import ( "github.com/urfave/cli/v3" ) -func GetStatusReportInfo(client status_reportv1connect.StatusReportServiceClient, reportId string) error { +type statusReportDetail struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Components []string `json:"components,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Updates []statusReportUpdate `json:"updates,omitempty"` +} + +type statusReportUpdate struct { + Date string `json:"date"` + Status string `json:"status"` + Message string `json:"message"` +} + +func GetStatusReportInfo(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string, s *output.Spinner) error { if reportId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus status-report info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus status-report info 12345") return fmt.Errorf("report ID is required") } - resp, err := client.GetStatusReport(context.Background(), &status_reportv1.GetStatusReportRequest{ + resp, err := client.GetStatusReport(ctx, &status_reportv1.GetStatusReportRequest{ Id: reportId, }) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("status report not found. Run 'openstatus status-report list' to see available reports") + return output.FormatError(err, "status-report", reportId) } report := resp.GetStatusReport() + if output.IsJSONOutput() { + detail := statusReportDetail{ + ID: report.GetId(), + Title: report.GetTitle(), + Status: statusToString(report.GetStatus()), + Components: report.GetPageComponentIds(), + CreatedAt: report.GetCreatedAt(), + UpdatedAt: report.GetUpdatedAt(), + } + for _, u := range report.GetUpdates() { + detail.Updates = append(detail.Updates, statusReportUpdate{ + Date: u.GetDate(), + Status: statusToString(u.GetStatus()), + Message: u.GetMessage(), + }) + } + return output.PrintJSON(detail) + } + fmt.Println(aurora.Bold("Status Report:")) table := tablewriter.NewTable(os.Stdout, tablewriter.WithRenderer(renderer.NewBlueprint()), @@ -90,29 +132,34 @@ func GetStatusReportInfo(client status_reportv1connect.StatusReportServiceClient return nil } -func GetStatusReportInfoWithHTTPClient(httpClient *http.Client, apiKey string, reportId string) error { +func GetStatusReportInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId string) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return GetStatusReportInfo(client, reportId) + return GetStatusReportInfo(ctx, client, reportId, nil) } func GetStatusReportInfoCmd() *cli.Command { return &cli.Command{ - Name: "info", - Usage: "Get status report details", - UsageText: "openstatus status-report info ", + Name: "info", + Usage: "Get status report details", + UsageText: `openstatus status-report info + openstatus status-report info 12345`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } reportId := cmd.Args().Get(0) - client := NewStatusReportClient(cmd.String("access-token")) - err := GetStatusReportInfo(client, reportId) + s := output.StartSpinner("Fetching status report...") + client := NewStatusReportClient(apiKey) + err = GetStatusReportInfo(ctx, client, reportId, s) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/statusreport/statusreport_info_test.go b/internal/statusreport/statusreport_info_test.go index c8d3bdd..9b1a5ee 100644 --- a/internal/statusreport/statusreport_info_test.go +++ b/internal/statusreport/statusreport_info_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -28,7 +29,7 @@ func Test_GetStatusReportInfo(t *testing.T) { }, } - err := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") + err := statusreport.GetStatusReportInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "1") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -50,7 +51,7 @@ func Test_GetStatusReportInfo(t *testing.T) { }, } - err := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "2") + err := statusreport.GetStatusReportInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "2") if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -68,7 +69,7 @@ func Test_GetStatusReportInfo(t *testing.T) { }, } - err := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "") + err := statusreport.GetStatusReportInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "") if err == nil { t.Error("Expected error for empty report ID, got nil") } @@ -93,7 +94,7 @@ func Test_GetStatusReportInfo(t *testing.T) { }, } - err := statusreport.GetStatusReportInfoWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "999") + err := statusreport.GetStatusReportInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "999") if err == nil { t.Error("Expected error, got nil") } diff --git a/internal/statusreport/statusreport_list.go b/internal/statusreport/statusreport_list.go index a78fcb7..7e5835d 100644 --- a/internal/statusreport/statusreport_list.go +++ b/internal/statusreport/statusreport_list.go @@ -7,12 +7,22 @@ import ( status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/fatih/color" "github.com/rodaine/table" "github.com/urfave/cli/v3" ) -func ListStatusReports(client status_reportv1connect.StatusReportServiceClient, statusFilter string, limit int) error { +type statusReportListEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func ListStatusReports(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, statusFilter string, limit int, s *output.Spinner) error { req := &status_reportv1.ListStatusReportsRequest{} if limit > 0 { @@ -28,12 +38,28 @@ func ListStatusReports(client status_reportv1connect.StatusReportServiceClient, req.SetStatuses([]status_reportv1.StatusReportStatus{s}) } - resp, err := client.ListStatusReports(context.Background(), req) + resp, err := client.ListStatusReports(ctx, req) + output.StopSpinner(s) if err != nil { - return fmt.Errorf("failed to list status reports: %w", err) + return output.FormatError(err, "status-report", "") } reports := resp.GetStatusReports() + + if output.IsJSONOutput() { + entries := make([]statusReportListEntry, 0, len(reports)) + for _, r := range reports { + entries = append(entries, statusReportListEntry{ + ID: r.GetId(), + Title: r.GetTitle(), + Status: statusToString(r.GetStatus()), + CreatedAt: r.GetCreatedAt(), + UpdatedAt: r.GetUpdatedAt(), + }) + } + return output.PrintJSON(entries) + } + if len(reports) == 0 { fmt.Println("No status reports found") return nil @@ -59,23 +85,23 @@ func ListStatusReports(client status_reportv1connect.StatusReportServiceClient, return nil } -func ListStatusReportsWithHTTPClient(httpClient *http.Client, apiKey string, statusFilter string, limit int) error { +func ListStatusReportsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, statusFilter string, limit int) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return ListStatusReports(client, statusFilter, limit) + return ListStatusReports(ctx, client, statusFilter, limit, nil) } func GetStatusReportListCmd() *cli.Command { return &cli.Command{ - Name: "list", - Usage: "List all status reports", - UsageText: "openstatus status-report list [options]", + Name: "list", + Usage: "List all status reports", + UsageText: `openstatus status-report list + openstatus status-report list --status investigating --limit 10`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ Name: "status", @@ -87,8 +113,13 @@ func GetStatusReportListCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - client := NewStatusReportClient(cmd.String("access-token")) - err := ListStatusReports(client, cmd.String("status"), int(cmd.Int("limit"))) + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + s := output.StartSpinner("Fetching status reports...") + client := NewStatusReportClient(apiKey) + err = ListStatusReports(ctx, client, cmd.String("status"), int(cmd.Int("limit")), s) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/statusreport/statusreport_list_test.go b/internal/statusreport/statusreport_list_test.go index 0c8f75a..021de86 100644 --- a/internal/statusreport/statusreport_list_test.go +++ b/internal/statusreport/statusreport_list_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "log" "net/http" @@ -35,7 +36,7 @@ func Test_ListStatusReports(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "", 0) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -57,7 +58,7 @@ func Test_ListStatusReports(t *testing.T) { }, } - err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "", 0) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -79,7 +80,7 @@ func Test_ListStatusReports(t *testing.T) { }, } - err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "investigating", 0) + err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "investigating", 0) if err != nil { t.Errorf("Expected no error, got %v", err) } @@ -97,7 +98,7 @@ func Test_ListStatusReports(t *testing.T) { }, } - err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "invalid", 0) + err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "invalid", 0) if err == nil { t.Error("Expected error for invalid status, got nil") } @@ -119,7 +120,7 @@ func Test_ListStatusReports(t *testing.T) { }, } - err := statusreport.ListStatusReportsWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "", 0) + err := statusreport.ListStatusReportsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(),"test-token", "", 0) if err == nil { t.Error("Expected error, got nil") } diff --git a/internal/statusreport/statusreport_update.go b/internal/statusreport/statusreport_update.go index 24276ed..f5f3630 100644 --- a/internal/statusreport/statusreport_update.go +++ b/internal/statusreport/statusreport_update.go @@ -4,15 +4,21 @@ import ( "context" "fmt" "net/http" + "os" "strings" status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_report/v1/status_reportv1connect" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func UpdateStatusReport(client status_reportv1connect.StatusReportServiceClient, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { +func UpdateStatusReport(ctx context.Context, client status_reportv1connect.StatusReportServiceClient, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { if reportId == "" { + fmt.Fprintln(os.Stderr, "Usage: openstatus status-report update [--title ...] [--component-ids ...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus status-report update 12345 --title \"Updated title\"") return fmt.Errorf("report ID is required") } @@ -32,18 +38,17 @@ func UpdateStatusReport(client status_reportv1connect.StatusReportServiceClient, req.SetPageComponentIds(componentIds) } - _, err := client.UpdateStatusReport(context.Background(), req) + _, err := client.UpdateStatusReport(ctx, req) if err != nil { - return fmt.Errorf("failed to update status report: %w", err) + return output.FormatError(err, "status-report", reportId) } - fmt.Printf("Status report %s updated successfully\n", reportId) return nil } -func UpdateStatusReportWithHTTPClient(httpClient *http.Client, apiKey string, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { +func UpdateStatusReportWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, reportId string, title string, componentIds []string, hasTitle bool, hasComponents bool) error { client := NewStatusReportClientWithHTTPClient(httpClient, apiKey) - return UpdateStatusReport(client, reportId, title, componentIds, hasTitle, hasComponents) + return UpdateStatusReport(ctx, client, reportId, title, componentIds, hasTitle, hasComponents) } func GetStatusReportUpdateCmd() *cli.Command { @@ -53,11 +58,10 @@ func GetStatusReportUpdateCmd() *cli.Command { UsageText: "openstatus status-report update [--title \"New title\"] [--component-ids id1,id2]", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }, &cli.StringFlag{ Name: "title", @@ -69,6 +73,10 @@ func GetStatusReportUpdateCmd() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } reportId := cmd.Args().Get(0) hasTitle := cmd.IsSet("title") @@ -79,11 +87,15 @@ func GetStatusReportUpdateCmd() *cli.Command { componentIds = strings.Split(ids, ",") } - client := NewStatusReportClient(cmd.String("access-token")) - err := UpdateStatusReport(client, reportId, cmd.String("title"), componentIds, hasTitle, hasComponents) + client := NewStatusReportClient(apiKey) + s := output.StartSpinner("Updating status report...") + err = UpdateStatusReport(ctx, client, reportId, cmd.String("title"), componentIds, hasTitle, hasComponents) + output.StopSpinner(s) if err != nil { return cli.Exit(err.Error(), 1) } + fmt.Printf("Status report %s updated successfully\n", reportId) + fmt.Println("Run 'openstatus status-report info " + reportId + "' to see the report") return nil }, } diff --git a/internal/statusreport/statusreport_update_test.go b/internal/statusreport/statusreport_update_test.go index 2fbe1fe..4606f6b 100644 --- a/internal/statusreport/statusreport_update_test.go +++ b/internal/statusreport/statusreport_update_test.go @@ -2,6 +2,7 @@ package statusreport_test import ( "bytes" + "context" "io" "net/http" "testing" @@ -29,7 +30,7 @@ func Test_UpdateStatusReport(t *testing.T) { } err := statusreport.UpdateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "New Title", nil, true, false, ) if err != nil { @@ -54,7 +55,7 @@ func Test_UpdateStatusReport(t *testing.T) { } err := statusreport.UpdateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "", []string{"c1", "c2"}, false, true, ) if err != nil { @@ -79,7 +80,7 @@ func Test_UpdateStatusReport(t *testing.T) { } err := statusreport.UpdateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "Updated Title", []string{"c3"}, true, true, ) if err != nil { @@ -100,7 +101,7 @@ func Test_UpdateStatusReport(t *testing.T) { } err := statusreport.UpdateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "1", "", nil, false, false, ) if err == nil { @@ -124,7 +125,7 @@ func Test_UpdateStatusReport(t *testing.T) { } err := statusreport.UpdateStatusReportWithHTTPClient( - interceptor.GetHTTPClient(), "test-token", + context.Background(), interceptor.GetHTTPClient(),"test-token", "", "Title", nil, true, false, ) if err == nil { diff --git a/internal/whoami/whoami.go b/internal/whoami/whoami.go index 4bf6e68..ac07570 100644 --- a/internal/whoami/whoami.go +++ b/internal/whoami/whoami.go @@ -8,6 +8,8 @@ import ( "net/http" "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) @@ -17,22 +19,23 @@ type Whoami struct { Plan string `json:"plan"` } -func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { +func GetWhoamiCmd(ctx context.Context, httpClient *http.Client, apiKey string, s *output.Spinner) error { url := fmt.Sprintf("%s/whoami", api.APIBaseURL) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Add("x-openstatus-key", apiKey) res, err := httpClient.Do(req) + output.StopSpinner(s) if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to get workspace information") + return fmt.Errorf("failed to get workspace information. Check your API token with OPENSTATUS_API_TOKEN env var") } body, err := io.ReadAll(res.Body) if err != nil { @@ -43,6 +46,11 @@ func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { if err != nil { return err } + + if output.IsJSONOutput() { + return output.PrintJSON(whoami) + } + fmt.Println("Name: ", whoami.Name) fmt.Println("Slug: ", whoami.Slug) fmt.Println("Plan: ", whoami.Plan) @@ -52,26 +60,33 @@ func GetWhoamiCmd(httpClient *http.Client, apiKey string) error { func WhoamiCmd() *cli.Command { whoamiCmd := cli.Command{ - Name: "whoami", - Usage: "Get your workspace information", - Aliases: []string{"w"}, - UsageText: "openstatus whoami [options]", - Description: "Get your current workspace information, display the workspace name, slug, and plan", + Name: "whoami", + Usage: "Get your workspace information", + Aliases: []string{"w"}, + UsageText: "openstatus whoami", + Description: `Get your current workspace information. +Displays the workspace name, slug, and plan.`, Action: func(ctx context.Context, cmd *cli.Command) error { - fmt.Println("Your current workspace information") - err := GetWhoamiCmd(http.DefaultClient, cmd.String("access-token")) + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + if !output.IsQuiet() && !output.IsJSONOutput() { + fmt.Println("Your current workspace information") + } + s := output.StartSpinner("Fetching workspace info...") + err = GetWhoamiCmd(ctx, http.DefaultClient, apiKey, s) if err != nil { - return cli.Exit("Failed to get workspace information", 1) + return cli.Exit(err.Error(), 1) } return nil }, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "access-token", - Usage: "OpenStatus API Access Token", - Aliases: []string{"t"}, - Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - Required: true, + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), }}, } return &whoamiCmd diff --git a/internal/whoami/whoami_test.go b/internal/whoami/whoami_test.go index 907bd2e..8ee2a07 100644 --- a/internal/whoami/whoami_test.go +++ b/internal/whoami/whoami_test.go @@ -2,6 +2,7 @@ package whoami_test import ( "bytes" + "context" "io" "log" "net/http" @@ -50,7 +51,7 @@ func Test_getWhoami(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := whoami.GetWhoamiCmd(interceptor.GetHTTPClient(), "") + err := whoami.GetWhoamiCmd(context.Background(), interceptor.GetHTTPClient(), "", nil) if err != nil { t.Error(err) t.Errorf("Expected log output, got nothing") @@ -72,7 +73,7 @@ func Test_getWhoami(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := whoami.GetWhoamiCmd(interceptor.GetHTTPClient(), "") + err := whoami.GetWhoamiCmd(context.Background(), interceptor.GetHTTPClient(), "", nil) if err == nil { t.Errorf("Expected log output, got nothing") }