feat: migrate CLI argument parsing from Cobra to Broccoli

lemon-mint committed Dec 11, 2025 at 17:41 UTC 53848637ec8b89e3ecae926174310340f6e47530
3 files changed +64 -62
cmd/portal-tunnel/main.go
+61 -50
@@ -12,7 +12,7 @@ import (
12 "syscall"
13
14 "github.com/rs/zerolog/log"
15 - "github.com/spf13/cobra"
15 + "gopkg.eu.org/broccoli"
16 "gosuda.org/portal/sdk"
17 "gosuda.org/portal/utils"
18 )
@@ -23,49 +23,60 @@ var bufferPool = sync.Pool{
23 New: func() any { return make([]byte, 64*1024) },
24 }
25
26 -var (
27 - flagConfigPath string
28 - flagRelayURLs string
29 - flagHost string
30 - flagName string
31 - flagDesc string
32 - flagTags string
33 - flagThumbnail string
34 - flagOwner string
35 - flagHide bool
36 -)
26 +type Config struct {
27 + _ struct{} `version:"0.0.1" command:"portal-tunnel" about:"Expose local services through Portal relay"`
28
38 -var rootCmd = &cobra.Command{
39 - Use: "portal-tunnel",
40 - Short: "Expose local services through Portal relay",
41 - RunE: func(cmd *cobra.Command, args []string) error {
42 - if flagConfigPath == "" {
43 - return runExposeWithFlags()
44 - }
45 - return runExposeWithConfig()
46 - },
47 -}
29 + ConfigPath string `flag:"config" alias:"c" env:"TUNNEL_CONFIG" about:"Path to portal-tunnel config file"`
30 + RelayURLs string `flag:"relay" env:"RELAYS" default:"ws://localhost:4017/relay" about:"Portal relay server URLs when config is not provided (comma-separated)"`
31 + Host string `flag:"host" env:"APP_HOST" about:"target host to proxy to when config is not provided (host:port or URL)"`
32 + Name string `flag:"name" env:"APP_NAME" about:"Service name when config is not provided"`
33
49 -func init() {
50 - rootCmd.Flags().StringVar(&flagConfigPath, "config", "", "Path to portal-tunnel config file")
51 - rootCmd.Flags().StringVar(&flagRelayURLs, "relay", "ws://localhost:4017/relay", "Portal relay server URLs when config is not provided (comma-separated)")
52 - rootCmd.Flags().StringVar(&flagHost, "host", "localhost:3000", "target host to proxy to when config is not provided (host:port or URL)")
53 - rootCmd.Flags().StringVar(&flagName, "name", "", "Service name when config is not provided (auto-generated if empty)")
54 - rootCmd.Flags().StringVar(&flagDesc, "description", "", "Service description metadata")
55 - rootCmd.Flags().StringVar(&flagTags, "tags", "", "Service tags metadata (comma-separated)")
56 - rootCmd.Flags().StringVar(&flagThumbnail, "thumbnail", "", "Service thumbnail URL metadata")
57 - rootCmd.Flags().StringVar(&flagOwner, "owner", "", "Service owner metadata")
58 - rootCmd.Flags().BoolVar(&flagHide, "hide", false, "Hide service from discovery (metadata)")
34 + // Metadata
35 + Description string `flag:"description" env:"APP_DESCRIPTION" about:"Service description metadata"`
36 + Tags string `flag:"tags" env:"APP_TAGS" about:"Service tags metadata (comma-separated)"`
37 + Thumbnail string `flag:"thumbnail" env:"APP_THUMBNAIL" about:"Service thumbnail URL metadata"`
38 + Owner string `flag:"owner" env:"APP_OWNER" about:"Service owner metadata"`
39 + Hide bool `flag:"hide" env:"APP_HIDE" about:"Hide service from discovery (metadata)"`
40 }
41
42 func main() {
62 - if err := rootCmd.Execute(); err != nil {
43 + var cfg Config
44 + app, err := broccoli.NewApp(&cfg)
45 + if err != nil {
46 + fmt.Fprintf(os.Stderr, "Error creating app: %v\n", err)
47 + os.Exit(1)
48 + }
49 +
50 + _, _, err = app.Bind(&cfg, os.Args[1:])
51 + if err != nil {
52 + if err == broccoli.ErrHelp {
53 + fmt.Print(app.Help())
54 + os.Exit(0)
55 + }
56 + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err)
57 + fmt.Print(app.Help())
58 + os.Exit(1)
59 + }
60 +
61 + var runErr error
62 + if cfg.ConfigPath != "" {
63 + runErr = runExposeWithConfig(cfg.ConfigPath)
64 + } else {
65 + if cfg.Host == "" || cfg.Name == "" {
66 + fmt.Print(app.Help())
67 + os.Exit(1)
68 + }
69 + runErr = runExposeWithFlags(cfg)
70 + }
71 +
72 + if runErr != nil {
73 + log.Error().Err(runErr).Msg("Exited with error")
74 os.Exit(1)
75 }
76 }
77
67 -func runExposeWithConfig() error {
68 - cfg, err := LoadConfig(flagConfigPath)
78 +func runExposeWithConfig(configPath string) error {
79 + cfg, err := LoadConfig(configPath)
80 if err != nil {
81 return fmt.Errorf("load config: %w", err)
82 }
@@ -88,7 +99,7 @@ func runExposeWithConfig() error {
99 cancel()
100 }()
101
91 - if err := runServiceTunnel(ctx, relayURLs, &cfg.Service, fmt.Sprintf("config=%s", flagConfigPath)); err != nil {
102 + if err := runServiceTunnel(ctx, relayURLs, &cfg.Service, fmt.Sprintf("config=%s", configPath)); err != nil {
103 return err
104 }
105
@@ -96,18 +107,18 @@ func runExposeWithConfig() error {
107 return nil
108 }
109
99 -func runExposeWithFlags() error {
100 - relayURLs := utils.ParseURLs(flagRelayURLs)
110 +func runExposeWithFlags(cfg Config) error {
111 + relayURLs := utils.ParseURLs(cfg.RelayURLs)
112 if len(relayURLs) == 0 {
113 return fmt.Errorf("--relay must include at least one non-empty URL when --config is not provided")
114 }
115
116 var metadata sdk.Metadata
106 - if strings.TrimSpace(flagDesc) != "" {
107 - metadata.Description = flagDesc
117 + if strings.TrimSpace(cfg.Description) != "" {
118 + metadata.Description = cfg.Description
119 }
109 - if strings.TrimSpace(flagTags) != "" {
110 - tags := strings.Split(flagTags, ",")
120 + if strings.TrimSpace(cfg.Tags) != "" {
121 + tags := strings.Split(cfg.Tags, ",")
122 for i := range tags {
123 tags[i] = strings.TrimSpace(tags[i])
124 }
@@ -119,19 +130,19 @@ func runExposeWithFlags() error {
130 }
131 metadata.Tags = filtered
132 }
122 - if strings.TrimSpace(flagThumbnail) != "" {
123 - metadata.Thumbnail = flagThumbnail
133 + if strings.TrimSpace(cfg.Thumbnail) != "" {
134 + metadata.Thumbnail = cfg.Thumbnail
135 }
125 - if strings.TrimSpace(flagOwner) != "" {
126 - metadata.Owner = flagOwner
136 + if strings.TrimSpace(cfg.Owner) != "" {
137 + metadata.Owner = cfg.Owner
138 }
128 - if flagHide {
129 - metadata.Hide = flagHide
139 + if cfg.Hide {
140 + metadata.Hide = cfg.Hide
141 }
142
143 service := &ServiceConfig{
133 - Name: strings.TrimSpace(flagName),
134 - Target: flagHost,
144 + Name: strings.TrimSpace(cfg.Name),
145 + Target: cfg.Host,
146 Metadata: metadata,
147 }
148 applyServiceDefaults(service)
go.mod
+1 -3
@@ -7,22 +7,20 @@ require (
7 github.com/hashicorp/yamux v0.1.2
8 github.com/planetscale/vtprotobuf v0.6.0
9 github.com/rs/zerolog v1.34.0
10 - github.com/spf13/cobra v1.10.2
10 github.com/stretchr/testify v1.11.1
11 github.com/valyala/bytebufferpool v1.0.0
12 golang.org/x/crypto v0.44.0
13 golang.org/x/net v0.47.0
14 google.golang.org/protobuf v1.36.10
15 + gopkg.eu.org/broccoli v1.2.2
16 gopkg.in/yaml.v3 v3.0.1
17 )
18
19 require (
20 github.com/davecgh/go-spew v1.1.1 // indirect
21 - github.com/inconshreveable/mousetrap v1.1.0 // indirect
21 github.com/mattn/go-colorable v0.1.14 // indirect
22 github.com/mattn/go-isatty v0.0.20 // indirect
23 github.com/pmezard/go-difflib v1.0.0 // indirect
25 - github.com/spf13/pflag v1.0.9 // indirect
24 golang.org/x/sys v0.38.0 // indirect
25 golang.org/x/text v0.31.0 // indirect
26 )
go.sum
+2 -9
@@ -1,5 +1,4 @@
1 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
2 -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -9,8 +8,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
8 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
9 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
10 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
12 -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
13 -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
11 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
12 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
13 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -26,16 +23,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
23 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
24 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
25 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
29 -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
30 -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
31 -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
32 -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
33 -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
26 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
27 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
28 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
29 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
38 -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
30 golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
31 golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
32 golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
@@ -49,6 +40,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
40 golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
41 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
42 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
43 +gopkg.eu.org/broccoli v1.2.2 h1:/VnfW2PzmROadiYG+hGPz6Hczp7vghiyPvjGGIN1b3o=
44 +gopkg.eu.org/broccoli v1.2.2/go.mod h1:eM8HnmLyfiQHAwqh2afErWYnAkkOvi+RXgoXBRhKMCQ=
45 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
46 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
47 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=