Skip to content

Commit 093b72c

Browse files
refactor: improve CLI reliability and architecture (#17)
- Thread context.Context through all API calls so Ctrl+C cancels in-flight requests - Fix race condition in `run` command: collect results per goroutine, print sequentially - Fix spinner/output overlap: stop spinner after API call, before rendering - Move ResolveAccessToken from internal/cli to internal/auth to avoid fragile dependency - Pass through original errors in monitor commands instead of swallowing them - Fix TOCTOU in monitor apply: compute diff locally, then apply once - Add fsync to lock file writes to prevent corruption on crash - Use net.SplitHostPort for TCP URI parsing to support IPv6 - Add login/logout commands with XDG-based token persistence - Add status report CRUD commands via Connect RPC - Add global --json, --quiet, --debug, --no-color flags - Add centralized error formatting for Connect RPC errors - Add signal handling with double Ctrl+C force exit
1 parent e59cde6 commit 093b72c

46 files changed

Lines changed: 1294 additions & 755 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/openstatus/main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
package main
22

33
import (
4-
"context"
54
cmd "github.com/openstatusHQ/cli/internal/cmd"
5+
"github.com/joho/godotenv"
66
"log"
7-
"os"
87
)
98

109
func main() {
10+
_ = godotenv.Load()
11+
1112
app := cmd.NewApp()
1213

13-
if err := app.Run(context.Background(), os.Args); err != nil {
14+
if err := cmd.RunApp(app); err != nil {
1415
log.Fatal(err)
1516
}
1617
}

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@ require (
88
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2
99
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1
1010
connectrpc.com/connect v1.19.1
11+
github.com/briandowns/spinner v1.23.2
1112
github.com/fatih/color v1.18.0
1213
github.com/google/go-cmp v0.7.0
14+
github.com/joho/godotenv v1.5.1
1315
github.com/knadh/koanf/parsers/yaml v0.1.0
1416
github.com/knadh/koanf/providers/file v1.1.2
1517
github.com/knadh/koanf/v2 v2.1.1
1618
github.com/logrusorgru/aurora/v4 v4.0.0
19+
github.com/mattn/go-isatty v0.0.20
1720
github.com/olekukonko/tablewriter v1.0.7
1821
github.com/rodaine/table v1.3.0
1922
github.com/urfave/cli-docs/v3 v3.0.0-alpha6
23+
golang.org/x/term v0.1.0
2024
sigs.k8s.io/yaml v1.4.0
2125
)
2226

@@ -27,7 +31,6 @@ require (
2731
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
2832
github.com/knadh/koanf/maps v0.1.2 // indirect
2933
github.com/mattn/go-colorable v0.1.13 // indirect
30-
github.com/mattn/go-isatty v0.0.20 // indirect
3134
github.com/mattn/go-runewidth v0.0.16 // indirect
3235
github.com/mitchellh/copystructure v1.2.0 // indirect
3336
github.com/mitchellh/reflectwalk v1.0.2 // indirect

go.sum

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg=
22
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
3-
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2 h1:nQzgK01nlgbSQn/7/qjHxSwcv/C6I2f9lMfhgJ98FV0=
4-
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2/go.mod h1:ikyyG3mJiNpeGcAywdhzGOt5fTSs78jvlvAqJo8ZYf4=
53
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2 h1:MeP+r7GwYHWKSMa1ltvtRNwzT+gCyHIYCEpNxWeNwS4=
64
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2/go.mod h1:W/PtF1QguqXdSkOHAD0VAOTMNfuESeNOdR2cF/CWeOQ=
7-
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1 h1:PeaMjGloj9U860iUOmX7pNwx2hdudlOus8ietWw7IWE=
8-
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM=
95
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1 h1:vw4PznfU8x7XrFtc/HHPjWfxNnFExtaSwrPS8cEKq+w=
106
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM=
117
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
128
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
9+
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
10+
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
1311
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
1412
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
1513
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
2523
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
2624
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2725
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
26+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
27+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
2828
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
2929
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
3030
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
7777
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7878
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
7979
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
80+
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
81+
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
8082
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
8183
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
8284
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

internal/api/client.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package api
22

33
import (
44
"context"
5+
"fmt"
6+
"os"
7+
"time"
58

9+
output "github.com/openstatusHQ/cli/internal/cli"
610
"connectrpc.com/connect"
711
)
812

@@ -14,7 +18,24 @@ func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc {
1418
return func(next connect.UnaryFunc) connect.UnaryFunc {
1519
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
1620
req.Header().Set("x-openstatus-key", apiKey)
17-
return next(ctx, req)
21+
22+
if output.IsDebug() {
23+
fmt.Fprintf(os.Stderr, "[debug] %s %s\n", req.HTTPMethod(), req.Spec().Procedure)
24+
}
25+
26+
start := time.Now()
27+
resp, err := next(ctx, req)
28+
29+
if output.IsDebug() {
30+
duration := time.Since(start)
31+
if err != nil {
32+
fmt.Fprintf(os.Stderr, "[debug] error after %s: %v\n", duration, err)
33+
} else {
34+
fmt.Fprintf(os.Stderr, "[debug] ok in %s\n", duration)
35+
}
36+
}
37+
38+
return resp, err
1839
}
1940
}
2041
}

internal/auth/auth.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/openstatusHQ/cli/internal/config"
9+
clilib "github.com/urfave/cli/v3"
10+
)
11+
12+
// ResolveAccessToken extracts the access token from CLI flags or falls back to saved token.
13+
func ResolveAccessToken(cmd *clilib.Command) (string, error) {
14+
return ResolveToken(cmd.String("access-token"))
15+
}
16+
17+
func ResolveToken(flagValue string) (string, error) {
18+
if flagValue != "" {
19+
return flagValue, nil
20+
}
21+
22+
tokenPath, err := config.TokenPath()
23+
if err == nil {
24+
data, readErr := os.ReadFile(tokenPath)
25+
if readErr == nil {
26+
token := strings.TrimSpace(string(data))
27+
if token != "" {
28+
return token, nil
29+
}
30+
}
31+
}
32+
33+
return "", fmt.Errorf("no API token found. Set OPENSTATUS_API_TOKEN env var, or run 'openstatus login'")
34+
}
35+
36+
func SaveToken(token string) error {
37+
dir, err := config.ConfigDir()
38+
if err != nil {
39+
return fmt.Errorf("failed to determine config directory: %w", err)
40+
}
41+
if err := os.MkdirAll(dir, 0700); err != nil {
42+
return fmt.Errorf("failed to create config directory: %w", err)
43+
}
44+
45+
tokenPath, err := config.TokenPath()
46+
if err != nil {
47+
return fmt.Errorf("failed to determine token path: %w", err)
48+
}
49+
50+
tmpFile, err := os.CreateTemp(dir, ".token-*")
51+
if err != nil {
52+
return fmt.Errorf("failed to create temp file: %w", err)
53+
}
54+
tmpPath := tmpFile.Name()
55+
56+
if _, err := tmpFile.WriteString(token); err != nil {
57+
tmpFile.Close()
58+
os.Remove(tmpPath)
59+
return fmt.Errorf("failed to write token: %w", err)
60+
}
61+
if err := tmpFile.Close(); err != nil {
62+
os.Remove(tmpPath)
63+
return fmt.Errorf("failed to close temp file: %w", err)
64+
}
65+
if err := os.Chmod(tmpPath, 0600); err != nil {
66+
os.Remove(tmpPath)
67+
return fmt.Errorf("failed to set token file permissions: %w", err)
68+
}
69+
if err := os.Rename(tmpPath, tokenPath); err != nil {
70+
os.Remove(tmpPath)
71+
return fmt.Errorf("failed to save token: %w", err)
72+
}
73+
return nil
74+
}
75+
76+
func RemoveToken() error {
77+
tokenPath, err := config.TokenPath()
78+
if err != nil {
79+
return fmt.Errorf("failed to determine token path: %w", err)
80+
}
81+
err = os.Remove(tokenPath)
82+
if os.IsNotExist(err) {
83+
return nil
84+
}
85+
if err != nil {
86+
return fmt.Errorf("failed to remove token: %w", err)
87+
}
88+
return nil
89+
}
90+

internal/cli/errors.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net"
7+
"strings"
8+
9+
"connectrpc.com/connect"
10+
)
11+
12+
func FormatError(err error, resource string, id string) error {
13+
if err == nil {
14+
return nil
15+
}
16+
17+
var connectErr *connect.Error
18+
if errors.As(err, &connectErr) {
19+
switch connectErr.Code() {
20+
case connect.CodeUnauthenticated:
21+
return fmt.Errorf("authentication failed. Check your API token via OPENSTATUS_API_TOKEN env var or --access-token flag. Verify with 'openstatus whoami'")
22+
case connect.CodePermissionDenied:
23+
return fmt.Errorf("permission denied. Check that your API token has access to this workspace")
24+
case connect.CodeNotFound:
25+
if id != "" {
26+
return fmt.Errorf("%s %s not found. Run 'openstatus %s list' to see available %ss", resource, id, resource, resource)
27+
}
28+
return fmt.Errorf("%s not found", resource)
29+
case connect.CodeResourceExhausted:
30+
return fmt.Errorf("rate limited. Wait a moment and try again")
31+
case connect.CodeInvalidArgument:
32+
return fmt.Errorf("invalid request: %s", connectErr.Message())
33+
}
34+
}
35+
36+
var dnsErr *net.DNSError
37+
if errors.As(err, &dnsErr) {
38+
return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection")
39+
}
40+
41+
var netErr *net.OpError
42+
if errors.As(err, &netErr) {
43+
return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection")
44+
}
45+
46+
if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "no such host") {
47+
return fmt.Errorf("could not reach api.openstatus.dev. Check your internet connection")
48+
}
49+
50+
return err
51+
}

internal/cli/output.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"sync/atomic"
8+
9+
"github.com/fatih/color"
10+
"github.com/mattn/go-isatty"
11+
)
12+
13+
var (
14+
jsonOutput atomic.Bool
15+
quietMode atomic.Bool
16+
debugMode atomic.Bool
17+
)
18+
19+
func SetJSONOutput(v bool) { jsonOutput.Store(v) }
20+
func SetQuietMode(v bool) { quietMode.Store(v) }
21+
func SetDebugMode(v bool) { debugMode.Store(v) }
22+
func IsJSONOutput() bool { return jsonOutput.Load() }
23+
func IsQuiet() bool { return quietMode.Load() }
24+
func IsDebug() bool { return debugMode.Load() }
25+
func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) }
26+
func IsStderrTerminal() bool {
27+
return isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())
28+
}
29+
30+
func PrintJSON(v any) error {
31+
data, err := json.MarshalIndent(v, "", " ")
32+
if err != nil {
33+
return fmt.Errorf("failed to marshal JSON: %w", err)
34+
}
35+
fmt.Println(string(data))
36+
return nil
37+
}
38+
39+
func InitColorSettings(noColorFlag bool) {
40+
if noColorFlag || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" || !IsTerminal() {
41+
color.NoColor = true
42+
}
43+
}

internal/cli/pager.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cli
2+
3+
import (
4+
"io"
5+
"os"
6+
"os/exec"
7+
"strings"
8+
)
9+
10+
// WithPager pipes output through the user's $PAGER (default: "less -FIRX").
11+
// Note: $PAGER is split by whitespace (strings.Fields), so paths with spaces
12+
// are not supported. This matches the behavior of git and gh.
13+
func WithPager(fn func(w io.Writer)) {
14+
if !IsTerminal() || IsJSONOutput() || IsQuiet() {
15+
fn(os.Stdout)
16+
return
17+
}
18+
19+
pager := os.Getenv("PAGER")
20+
if pager == "" {
21+
pager = "less -FIRX"
22+
}
23+
24+
parts := strings.Fields(pager)
25+
if len(parts) == 0 {
26+
fn(os.Stdout)
27+
return
28+
}
29+
30+
cmd := exec.Command(parts[0], parts[1:]...)
31+
cmd.Stdout = os.Stdout
32+
cmd.Stderr = os.Stderr
33+
34+
w, err := cmd.StdinPipe()
35+
if err != nil {
36+
fn(os.Stdout)
37+
return
38+
}
39+
40+
if err := cmd.Start(); err != nil {
41+
fn(os.Stdout)
42+
return
43+
}
44+
45+
fn(w)
46+
w.Close()
47+
cmd.Wait()
48+
}

internal/cli/spinner.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"time"
7+
8+
"github.com/briandowns/spinner"
9+
)
10+
11+
// Spinner is a type alias so callers don't need to import the spinner package directly.
12+
type Spinner = spinner.Spinner
13+
14+
func StartSpinner(message string) *Spinner {
15+
if !IsStderrTerminal() || IsJSONOutput() || IsQuiet() {
16+
return nil
17+
}
18+
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
19+
s.Suffix = " " + message
20+
s.Start()
21+
return s
22+
}
23+
24+
func StopSpinner(s *spinner.Spinner) {
25+
if s != nil {
26+
s.Stop()
27+
fmt.Fprintln(os.Stderr)
28+
}
29+
}

0 commit comments

Comments
 (0)