Skip to content

Commit 58dddc2

Browse files
appleboyclaude
andauthored
feat(cli): migrate to Cobra and add token get command (#21)
* feat(cli): migrate CLI to Cobra with token management - Migrate the CLI from the standard flag package to Cobra, introducing a root command, subcommands, and proper version handling - Refactor configuration and flag handling to use Cobra persistent flags and plain variables instead of pointer flags - Split configuration initialization to allow minimal setup for local-only commands like token get - Add a token command with a get subcommand to print stored access tokens, with optional JSON output - Add tests covering token get behavior, including JSON output and missing-token errors - Update dependencies to include Cobra and its related packages - Simplify string prefix handling in the TUI token storage display using newer standard library helpers Signed-off-by: Bo-Yi Wu <[email protected]> * ci: remove make generate step from testing workflow Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> * fix(cli): address code review feedback on Cobra migration - Print Cobra errors to stderr in main() instead of silently exiting; introduce exitCodeError to propagate non-zero exit codes through Cobras error chain without printing a redundant message - Replace os.Exit calls inside RunE with exitCodeError returns so defers run correctly and commands remain testable - Add Args: cobra.NoArgs to token get to reject unexpected positional args - Handle JSON encode error in runTokenGet instead of ignoring it - Remove refresh_token from --json output to avoid leaking long-lived secrets into logs and shell history - Check Store.Save errors in tests with t.Fatalf for clearer diagnostics Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]> --------- Signed-off-by: Bo-Yi Wu <[email protected]> Co-authored-by: Claude Sonnet 4.6 (1M context) <[email protected]>
1 parent f451fc6 commit 58dddc2

8 files changed

Lines changed: 336 additions & 111 deletions

File tree

.github/workflows/testing.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ jobs:
3232
with:
3333
go-version: ${{ matrix.go }}
3434

35-
- name: Run Generate
36-
run: |
37-
make generate
38-
3935
- name: Setup golangci-lint
4036
uses: golangci/golangci-lint-action@v9
4137
with:

config.go

Lines changed: 80 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package main
33
import (
44
"crypto/tls"
55
"errors"
6-
"flag"
76
"fmt"
87
"net/http"
98
"net/url"
@@ -18,35 +17,36 @@ import (
1817
retry "github.com/appleboy/go-httpretry"
1918
"github.com/google/uuid"
2019
"github.com/joho/godotenv"
20+
"github.com/spf13/cobra"
2121
)
2222

2323
// version is set at build time via -ldflags "-X main.version=...".
2424
var version string
2525

2626
var (
27-
serverURL string
28-
clientID string
29-
clientSecret string
30-
redirectURI string
31-
callbackPort int
32-
scope string
33-
tokenFile string
34-
tokenStoreMode string
35-
forceDevice bool
36-
configInitialized bool
37-
retryClient *retry.Client
38-
tokenStore credstore.Store[credstore.Token]
27+
serverURL string
28+
clientID string
29+
clientSecret string
30+
redirectURI string
31+
callbackPort int
32+
scope string
33+
tokenFile string
34+
tokenStoreMode string
35+
forceDevice bool
36+
storeConfigInitialized bool
37+
configInitialized bool
38+
retryClient *retry.Client
39+
tokenStore credstore.Store[credstore.Token]
3940

40-
flagServerURL *string
41-
flagClientID *string
42-
flagClientSecret *string
43-
flagRedirectURI *string
44-
flagCallbackPort *int
45-
flagScope *string
46-
flagTokenFile *string
47-
flagTokenStore *string
48-
flagDevice *bool
49-
flagVersion *bool
41+
flagServerURL string
42+
flagClientID string
43+
flagClientSecret string
44+
flagRedirectURI string
45+
flagCallbackPort int
46+
flagScope string
47+
flagTokenFile string
48+
flagTokenStore string
49+
flagDevice bool
5050
)
5151

5252
const (
@@ -58,47 +58,56 @@ const (
5858
defaultKeyringService = "authgate-cli"
5959
)
6060

61-
func init() {
61+
func registerFlags(cmd *cobra.Command) {
6262
_ = godotenv.Load()
63+
cmd.PersistentFlags().
64+
StringVar(&flagServerURL, "server-url", "", "OAuth server URL (default: http://localhost:8080 or SERVER_URL env)")
65+
cmd.PersistentFlags().
66+
StringVar(&flagClientID, "client-id", "", "OAuth client ID (required, or set CLIENT_ID env)")
67+
cmd.PersistentFlags().
68+
StringVar(&flagClientSecret, "client-secret", "", "OAuth client secret (confidential clients only; omit for public/PKCE clients)")
69+
cmd.PersistentFlags().
70+
StringVar(&flagRedirectURI, "redirect-uri", "", "Redirect URI registered with the OAuth server (default: http://localhost:PORT/callback)")
71+
cmd.PersistentFlags().
72+
IntVar(&flagCallbackPort, "port", 0, "Local callback port for browser flow (default: 8888 or CALLBACK_PORT env)")
73+
cmd.PersistentFlags().
74+
StringVar(&flagScope, "scope", "", "Space-separated OAuth scopes (default: \"read write\")")
75+
cmd.PersistentFlags().
76+
StringVar(&flagTokenFile, "token-file", "", "Token storage file (default: .authgate-tokens.json or TOKEN_FILE env)")
77+
cmd.PersistentFlags().
78+
StringVar(&flagTokenStore, "token-store", "", "Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)")
79+
cmd.PersistentFlags().
80+
BoolVar(&flagDevice, "device", false, "Force Device Code Flow (skip browser detection)")
81+
}
82+
83+
// initStoreConfig initialises only the token store and client ID — the minimum
84+
// needed for commands like `token get` that read local credentials without
85+
// making any network calls.
86+
func initStoreConfig() {
87+
if storeConfigInitialized {
88+
return
89+
}
90+
storeConfigInitialized = true
91+
92+
clientID = getConfig(flagClientID, "CLIENT_ID", "")
93+
tokenFile = getConfig(flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
94+
tokenStoreMode = getConfig(flagTokenStore, "TOKEN_STORE", "auto")
95+
96+
if clientID == "" {
97+
fmt.Fprintln(os.Stderr, "Error: CLIENT_ID not set. Please provide it via:")
98+
fmt.Fprintln(os.Stderr, " 1. Command-line flag: --client-id=<your-client-id>")
99+
fmt.Fprintln(os.Stderr, " 2. Environment variable: CLIENT_ID=<your-client-id>")
100+
fmt.Fprintln(os.Stderr, " 3. .env file: CLIENT_ID=<your-client-id>")
101+
fmt.Fprintln(os.Stderr, "\nYou can find the client_id in the server startup logs.")
102+
os.Exit(1)
103+
}
63104

64-
flagServerURL = flag.String(
65-
"server-url",
66-
"",
67-
"OAuth server URL (default: http://localhost:8080 or SERVER_URL env)",
68-
)
69-
flagClientID = flag.String("client-id", "", "OAuth client ID (required, or set CLIENT_ID env)")
70-
flagClientSecret = flag.String(
71-
"client-secret",
72-
"",
73-
"OAuth client secret (confidential clients only; omit for public/PKCE clients)",
74-
)
75-
flagRedirectURI = flag.String(
76-
"redirect-uri",
77-
"",
78-
"Redirect URI registered with the OAuth server (default: http://localhost:PORT/callback)",
79-
)
80-
flagCallbackPort = flag.Int(
81-
"port",
82-
0,
83-
"Local callback port for browser flow (default: 8888 or CALLBACK_PORT env)",
84-
)
85-
flagScope = flag.String("scope", "", "Space-separated OAuth scopes (default: \"read write\")")
86-
flagTokenFile = flag.String(
87-
"token-file",
88-
"",
89-
"Token storage file (default: .authgate-tokens.json or TOKEN_FILE env)",
90-
)
91-
flagTokenStore = flag.String(
92-
"token-store",
93-
"",
94-
"Token storage backend: auto, file, keyring (default: auto or TOKEN_STORE env)",
95-
)
96-
flagDevice = flag.Bool(
97-
"device",
98-
false,
99-
"Force Device Code Flow (skip browser detection)",
100-
)
101-
flagVersion = flag.Bool("version", false, "Print version and exit")
105+
var storeErr error
106+
tokenStore, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService)
107+
if storeErr != nil {
108+
fmt.Fprintln(os.Stderr, storeErr)
109+
os.Exit(1)
110+
}
102111
}
103112

104113
func initConfig() {
@@ -107,27 +116,20 @@ func initConfig() {
107116
}
108117
configInitialized = true
109118

110-
flag.Parse()
111-
112-
// --version prints build version and exits immediately.
113-
if *flagVersion {
114-
fmt.Println(getVersion())
115-
os.Exit(0)
116-
}
119+
// initStoreConfig sets clientID, tokenFile, tokenStoreMode, and tokenStore.
120+
initStoreConfig()
117121

118122
// --device forces Device Code Flow unconditionally.
119-
forceDevice = *flagDevice
123+
forceDevice = flagDevice
120124

121-
serverURL = getConfig(*flagServerURL, "SERVER_URL", "http://localhost:8080")
122-
clientID = getConfig(*flagClientID, "CLIENT_ID", "")
123-
clientSecret = getConfig(*flagClientSecret, "CLIENT_SECRET", "")
124-
scope = getConfig(*flagScope, "SCOPE", "read write")
125-
tokenFile = getConfig(*flagTokenFile, "TOKEN_FILE", ".authgate-tokens.json")
125+
serverURL = getConfig(flagServerURL, "SERVER_URL", "http://localhost:8080")
126+
clientSecret = getConfig(flagClientSecret, "CLIENT_SECRET", "")
127+
scope = getConfig(flagScope, "SCOPE", "read write")
126128

127129
// Resolve callback port (int flag needs special handling).
128130
portStr := ""
129-
if *flagCallbackPort != 0 {
130-
portStr = strconv.Itoa(*flagCallbackPort)
131+
if flagCallbackPort != 0 {
132+
portStr = strconv.Itoa(flagCallbackPort)
131133
}
132134
portStr = getConfig(portStr, "CALLBACK_PORT", "8888")
133135
if _, err := fmt.Sscanf(portStr, "%d", &callbackPort); err != nil || callbackPort <= 0 {
@@ -136,7 +138,7 @@ func initConfig() {
136138

137139
// Resolve redirect URI (depends on port, so compute after port is known).
138140
defaultRedirectURI := fmt.Sprintf("http://localhost:%d/callback", callbackPort)
139-
redirectURI = getConfig(*flagRedirectURI, "REDIRECT_URI", defaultRedirectURI)
141+
redirectURI = getConfig(flagRedirectURI, "REDIRECT_URI", defaultRedirectURI)
140142

141143
if err := validateServerURL(serverURL); err != nil {
142144
fmt.Fprintf(os.Stderr, "Error: Invalid SERVER_URL: %v\n", err)
@@ -155,15 +157,6 @@ func initConfig() {
155157
fmt.Fprintln(os.Stderr)
156158
}
157159

158-
if clientID == "" {
159-
fmt.Println("Error: CLIENT_ID not set. Please provide it via:")
160-
fmt.Println(" 1. Command-line flag: -client-id=<your-client-id>")
161-
fmt.Println(" 2. Environment variable: CLIENT_ID=<your-client-id>")
162-
fmt.Println(" 3. .env file: CLIENT_ID=<your-client-id>")
163-
fmt.Println("\nYou can find the client_id in the server startup logs.")
164-
os.Exit(1)
165-
}
166-
167160
if _, err := uuid.Parse(clientID); err != nil {
168161
fmt.Fprintf(
169162
os.Stderr,
@@ -188,14 +181,6 @@ func initConfig() {
188181
panic(fmt.Sprintf("failed to create retry client: %v", err))
189182
}
190183

191-
// Initialize token store based on mode
192-
tokenStoreMode = getConfig(*flagTokenStore, "TOKEN_STORE", "auto")
193-
var storeErr error
194-
tokenStore, storeErr = newTokenStore(tokenStoreMode, tokenFile, defaultKeyringService)
195-
if storeErr != nil {
196-
fmt.Fprintln(os.Stderr, storeErr)
197-
os.Exit(1)
198-
}
199184
if tokenStoreMode == "auto" {
200185
if ss, ok := tokenStore.(*credstore.SecureStore[credstore.Token]); ok && !ss.UseKeyring() {
201186
fmt.Fprintln(

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/google/uuid v1.6.0
1212
github.com/joho/godotenv v1.5.1
1313
github.com/mattn/go-isatty v0.0.20
14+
github.com/spf13/cobra v1.10.2
1415
golang.org/x/oauth2 v0.36.0
1516
golang.org/x/term v0.41.0
1617
)
@@ -28,10 +29,12 @@ require (
2829
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
2930
github.com/danieljoos/wincred v1.2.3 // indirect
3031
github.com/godbus/dbus/v5 v5.2.2 // indirect
32+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3133
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3234
github.com/mattn/go-runewidth v0.0.21 // indirect
3335
github.com/muesli/cancelreader v0.2.2 // indirect
3436
github.com/rivo/uniseg v0.4.7 // indirect
37+
github.com/spf13/pflag v1.0.9 // indirect
3538
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3639
github.com/zalando/go-keyring v0.2.6 // indirect
3740
golang.org/x/sync v0.20.0 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
3030
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
3131
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
3232
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
3334
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
3435
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
3536
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,6 +43,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
4243
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
4344
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4445
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
46+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
47+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4548
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
4649
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
4750
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -56,6 +59,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
5659
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5760
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
5861
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
62+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
63+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
64+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
65+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
66+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
5967
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
6068
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
6169
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -64,6 +72,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
6472
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
6573
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
6674
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
75+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
6776
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
6877
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
6978
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -75,5 +84,6 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
7584
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
7685
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
7786
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
87+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7888
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
7989
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,58 @@ import (
1818

1919
retry "github.com/appleboy/go-httpretry"
2020
"github.com/go-authgate/cli/tui"
21+
"github.com/spf13/cobra"
2122
)
2223

24+
// exitCodeError carries a non-zero exit code through Cobra's error chain
25+
// without printing a redundant message (the command already wrote to stderr).
26+
type exitCodeError int
27+
28+
func (e exitCodeError) Error() string { return "" }
29+
2330
func main() {
24-
initConfig()
31+
if err := buildRootCmd().Execute(); err != nil {
32+
var exitErr exitCodeError
33+
if errors.As(err, &exitErr) {
34+
os.Exit(int(exitErr))
35+
}
36+
fmt.Fprintln(os.Stderr, err)
37+
os.Exit(1)
38+
}
39+
}
2540

26-
// Select UI manager based on environment detection
27-
uiManager := tui.SelectManager()
41+
func buildRootCmd() *cobra.Command {
42+
rootCmd := &cobra.Command{
43+
Use: "authgate-cli",
44+
Short: "OAuth 2.0 authentication CLI",
45+
Version: getVersion(),
46+
SilenceUsage: true,
47+
SilenceErrors: true,
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
initConfig()
50+
uiManager := tui.SelectManager()
51+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
52+
defer stop()
53+
if code := run(ctx, uiManager); code != 0 {
54+
return exitCodeError(code)
55+
}
56+
return nil
57+
},
58+
}
59+
registerFlags(rootCmd)
60+
rootCmd.AddCommand(buildVersionCmd())
61+
rootCmd.AddCommand(buildTokenCmd())
62+
return rootCmd
63+
}
2864

29-
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
30-
exitCode := run(ctx, uiManager)
31-
stop()
32-
os.Exit(exitCode)
65+
func buildVersionCmd() *cobra.Command {
66+
return &cobra.Command{
67+
Use: "version",
68+
Short: "Print version",
69+
Run: func(cmd *cobra.Command, args []string) {
70+
fmt.Println(getVersion())
71+
},
72+
}
3373
}
3474

3575
func run(ctx context.Context, ui tui.Manager) int {

0 commit comments

Comments
 (0)