refactor: replace Cobra with flag package for command-line arguments

Kim committed Nov 4, 2025 at 18:54 UTC 7ab7dd6df1e0ea1850561dc61f912e664de5d705
4 files changed +109 -99
cmd/demo-app/main.go
+10 -16
@@ -2,6 +2,7 @@ package main
2
3 import (
4 "embed"
5 + "flag"
6 "fmt"
7 "io/fs"
8 "net/http"
@@ -12,7 +13,6 @@ import (
13
14 "github.com/gorilla/websocket"
15 "github.com/rs/zerolog/log"
15 - "github.com/spf13/cobra"
16
17 "gosuda.org/portal/sdk"
18 )
@@ -20,27 +20,21 @@ import (
20 //go:embed static
21 var staticFiles embed.FS
22
23 -var rootCmd = &cobra.Command{
24 - Use: "demo app",
25 - Short: "demo app using portal relay",
26 - RunE: runPaint,
27 -}
28 -
23 var (
24 flagServerURL string
25 flagPort int
26 flagName string
27 )
28
35 -func init() {
36 - flags := rootCmd.PersistentFlags()
37 - flags.StringVar(&flagServerURL, "server-url", "ws://localhost:4017/relay", "relay websocket URL")
38 - flags.IntVar(&flagPort, "port", 8092, "local paint HTTP port")
39 - flags.StringVar(&flagName, "name", "demo-app", "backend display name")
40 -}
41 -
29 func main() {
43 - if err := rootCmd.Execute(); err != nil {
30 + // Define flags equivalent to previous Cobra flags
31 + flag.StringVar(&flagServerURL, "server-url", "ws://localhost:4017/relay", "relay websocket URL")
32 + flag.IntVar(&flagPort, "port", 8092, "local paint HTTP port")
33 + flag.StringVar(&flagName, "name", "demo-app", "backend display name")
34 +
35 + flag.Parse()
36 +
37 + if err := runPaint(); err != nil {
38 log.Fatal().Err(err).Msg("execute paint command")
39 }
40 }
@@ -168,7 +162,7 @@ func (c *Canvas) handleWS(w http.ResponseWriter, r *http.Request) {
162 }
163 }
164
171 -func runPaint(cmd *cobra.Command, args []string) error {
165 +func runPaint() error {
166 // 1) Create credential for this paint app
167 cred := sdk.NewCredential()
168
cmd/portal-tunnel/main.go
+73 -58
@@ -2,69 +2,76 @@ package main
2
3 import (
4 "context"
5 + "flag"
6 "fmt"
7 "io"
8 "net"
9 "os"
10 "os/signal"
11 "syscall"
12 + "time"
13
14 "github.com/rs/zerolog/log"
13 - "github.com/spf13/cobra"
15 "gosuda.org/portal/sdk"
16 )
17
18 var (
18 - flagRelayURL string
19 - flagLocalPort int
20 - flagName string
21 - flagLocalHost string
19 + flagRelayURL string
20 + flagHost string
21 + flagPort string
22 + flagName string
23 )
24
24 -var rootCmd = &cobra.Command{
25 - Use: "portal-tunnel",
26 - Short: "Expose local services through Portal relay (like cloudflared tunnel)",
27 - Long: `Portal Tunnel exposes your local services to the internet through a secure Portal relay.
28 -
29 -Example:
30 - portal-tunnel expose 8080 --name my-service
31 - portal-tunnel expose 3000 --name api --relay ws://my-relay.com/relay
32 -`,
33 -}
34 -
35 -var exposeCmd = &cobra.Command{
36 - Use: "expose [local-port]",
37 - Short: "Expose a local port through the relay",
38 - Args: cobra.ExactArgs(1),
39 - RunE: runExpose,
40 -}
25 +func main() {
26 + if len(os.Args) < 2 {
27 + printTunnelUsage()
28 + os.Exit(2)
29 + }
30
42 -func init() {
43 - exposeCmd.Flags().StringVar(&flagRelayURL, "relay", "ws://localhost:4017/relay", "Portal relay server URL")
44 - exposeCmd.Flags().StringVar(&flagName, "name", "", "Service name (will be generated if not provided)")
45 - exposeCmd.Flags().StringVar(&flagLocalHost, "local-host", "localhost", "Local host to proxy to")
31 + switch os.Args[1] {
32 + case "expose":
33 + fs := flag.NewFlagSet("expose", flag.ExitOnError)
34 + fs.StringVar(&flagRelayURL, "relay", "ws://localhost:4017/relay", "Portal relay server URL")
35 + fs.StringVar(&flagHost, "host", "localhost", "Local host to proxy to")
36 + fs.StringVar(&flagPort, "port", "4018", "Local port to proxy to")
37 + fs.StringVar(&flagName, "name", "", "Service name (will be generated if not provided)")
38 + _ = fs.Parse(os.Args[2:])
39 +
40 + // Backward-compat: allow positional [local-port] if --local-port not provided
41 + if flagPort == "0" || flagPort == "" {
42 + extra := fs.Args()
43 + if len(extra) == 1 {
44 + flagPort = extra[0]
45 + }
46 + }
47
47 - rootCmd.AddCommand(exposeCmd)
48 + if err := runExpose(); err != nil {
49 + log.Fatal().Err(err).Msg("Failed to expose")
50 + }
51 + case "-h", "--help", "help":
52 + printTunnelUsage()
53 + default:
54 + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
55 + printTunnelUsage()
56 + os.Exit(2)
57 + }
58 }
59
50 -func main() {
51 - if err := rootCmd.Execute(); err != nil {
52 - log.Fatal().Err(err).Msg("Failed to execute command")
53 - }
60 +func printTunnelUsage() {
61 + fmt.Println("portal-tunnel — Expose local services through Portal relay")
62 + fmt.Println()
63 + fmt.Println("Usage:")
64 + fmt.Println(" portal-tunnel expose [port PORT] [--relay URL] [--name NAME] [--host HOST]")
65 }
66
56 -func runExpose(cmd *cobra.Command, args []string) error {
57 - // Parse local port
58 - var port string = args[0]
59 - localAddr := fmt.Sprintf("%s:%s", flagLocalHost, port)
67 +func runExpose() error {
68 + localAddr := net.JoinHostPort(flagHost, flagPort)
69
61 - // Test local service connectivity
62 - log.Info().Msgf("Testing connection to local service at %s...", localAddr)
63 - testConn, err := net.Dial("tcp", localAddr)
64 - if err != nil {
65 - return fmt.Errorf("cannot connect to local service at %s: %w", localAddr, err)
70 + // Always wait until the local service is available
71 + log.Info().Msgf("Waiting for local service at %s (interval=%v)...", localAddr, time.Second)
72 + if err := waitForLocalService(localAddr, 0, time.Second); err != nil {
73 + return err
74 }
67 - testConn.Close()
75 log.Info().Msgf("✓ Local service is reachable at %s", localAddr)
76
77 // Create credential
@@ -99,17 +106,12 @@ func runExpose(cmd *cobra.Command, args []string) error {
106 defer listener.Close()
107
108 log.Info().Msg("")
102 - log.Info().Msg("┌─────────────────────────────────────────────────────────────┐")
103 - log.Info().Msgf("│ 🌐 Service is now publicly accessible! │")
104 - log.Info().Msg("├─────────────────────────────────────────────────────────────┤")
105 - log.Info().Msgf("│ Access via: │")
106 - log.Info().Msgf("│ - Name: /peer/%s │", padRight(flagName, 30))
107 - log.Info().Msgf("│ - Lease ID: /peer/%s │", padRight(leaseID[:26], 30))
108 - log.Info().Msg("│ │")
109 - log.Info().Msgf("│ Example: │")
109 + log.Info().Msg("=== Service is now publicly accessible ===")
110 + log.Info().Msg("Access via:")
111 + log.Info().Msgf("- Name: /peer/%s", flagName)
112 + log.Info().Msgf("- Lease ID: /peer/%s", leaseID)
113 relayHost := extractHost(flagRelayURL)
111 - log.Info().Msgf("│ http://%s/peer/%s │", padRight(relayHost, 20), padRight(flagName, 20))
112 - log.Info().Msg("└─────────────────────────────────────────────────────────────┘")
114 + log.Info().Msgf("- Example: http://%s/peer/%s", relayHost, flagName)
115 log.Info().Msg("")
116 log.Info().Msg("Press Ctrl+C to stop...")
117 log.Info().Msg("")
@@ -203,13 +205,6 @@ func proxyConnection(relayConn net.Conn, localAddr string, connNum int) error {
205 return err
206 }
207
206 -func padRight(s string, length int) string {
207 - if len(s) >= length {
208 - return s
209 - }
210 - return s + string(make([]byte, length-len(s)))
211 -}
212 -
208 func extractHost(wsURL string) string {
209 // Simple extraction: ws://host:port/path -> host:port
210 // Remove ws:// or wss://
@@ -233,3 +228,23 @@ func extractHost(wsURL string) string {
228
229 return host
230 }
231 +
232 +// waitForLocalService tries to connect repeatedly until success or timeout.
233 +// If timeout == 0, it waits indefinitely.
234 +func waitForLocalService(localAddr string, timeout, interval time.Duration) error {
235 + deadline := time.Time{}
236 + if timeout > 0 {
237 + deadline = time.Now().Add(timeout)
238 + }
239 + for {
240 + conn, err := net.DialTimeout("tcp", localAddr, 2*time.Second)
241 + if err == nil {
242 + conn.Close()
243 + return nil
244 + }
245 + if !deadline.IsZero() && time.Now().After(deadline) {
246 + return fmt.Errorf("timeout waiting for local service at %s: %w", localAddr, err)
247 + }
248 + time.Sleep(interval)
249 + }
250 +}
cmd/relay-server/main.go
+26 -22
@@ -2,26 +2,21 @@ package main
2
3 import (
4 "context"
5 + "flag"
6 "fmt"
7 "os"
8 "os/signal"
9 + "strings"
10 "syscall"
11 "time"
12
13 "github.com/rs/zerolog"
14 "github.com/rs/zerolog/log"
13 - "github.com/spf13/cobra"
15
16 "gosuda.org/portal/portal"
17 "gosuda.org/portal/sdk"
18 )
19
19 -var rootCmd = &cobra.Command{
20 - Use: "portal",
21 - Short: "A lightweight, DNS-driven peer-to-peer proxy",
22 - RunE: runServer,
23 -}
24 -
20 var (
21 flagBootstraps []string
22 flagALPN string
@@ -30,34 +25,43 @@ var (
25 flagPortalDomain string
26 )
27
33 -func init() {
34 - // Get default values from environment variables
28 +func main() {
29 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
30 +
31 + // Defaults from environment
32 defaultStaticDir := os.Getenv("STATIC_DIR")
33 if defaultStaticDir == "" {
34 defaultStaticDir = "./dist"
35 }
39 -
36 defaultPortalDomain := os.Getenv("PORTAL_DOMAIN")
37 if defaultPortalDomain == "" {
38 defaultPortalDomain = "portal.gosuda.org"
39 }
40 + var flagBootstrapsCSV string
41 + flag.StringVar(&flagBootstrapsCSV, "bootstraps", "ws://localhost:4017/relay", "bootstrap addresses (comma-separated)")
42 + flag.StringVar(&flagALPN, "alpn", "http/1.1", "ALPN identifier for this service")
43 + flag.IntVar(&flagPort, "port", 4017, "admin UI and HTTP proxy port")
44 + flag.StringVar(&flagStaticDir, "static-dir", defaultStaticDir, "static files directory for portal frontend (env: STATIC_DIR)")
45 + flag.StringVar(&flagPortalDomain, "portal-domain", defaultPortalDomain, "portal domain for frontend serving (env: PORTAL_DOMAIN)")
46 +
47 + flag.Parse()
48 +
49 + // Parse bootstrap list
50 + parts := strings.Split(flagBootstrapsCSV, ",")
51 + flagBootstraps = make([]string, 0, len(parts))
52 + for _, p := range parts {
53 + s := strings.TrimSpace(p)
54 + if s != "" {
55 + flagBootstraps = append(flagBootstraps, s)
56 + }
57 + }
58
45 - flags := rootCmd.PersistentFlags()
46 - flags.StringArrayVar(&flagBootstraps, "bootstraps", []string{"ws://localhost:4017/relay"}, "bootstrap addresses")
47 - flags.StringVar(&flagALPN, "alpn", "http/1.1", "ALPN identifier for this service")
48 - flags.IntVar(&flagPort, "port", 4017, "admin UI and HTTP proxy port")
49 - flags.StringVar(&flagStaticDir, "static-dir", defaultStaticDir, "static files directory for portal frontend (env: STATIC_DIR)")
50 - flags.StringVar(&flagPortalDomain, "portal-domain", defaultPortalDomain, "portal domain for frontend serving (env: PORTAL_DOMAIN)")
51 -}
52 -
53 -func main() {
54 - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
55 - if err := rootCmd.Execute(); err != nil {
59 + if err := runServer(); err != nil {
60 log.Fatal().Err(err).Msg("execute root command")
61 }
62 }
63
60 -func runServer(cmd *cobra.Command, args []string) error {
64 +func runServer() error {
65 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
66 defer stop()
67
go.mod
-3
@@ -8,7 +8,6 @@ require (
8 github.com/hashicorp/yamux v0.1.2
9 github.com/planetscale/vtprotobuf v0.6.0
10 github.com/rs/zerolog v1.34.0
11 - github.com/spf13/cobra v1.10.1
11 github.com/valyala/bytebufferpool v1.0.0
12 golang.org/x/crypto v0.43.0
13 golang.org/x/net v0.46.0
@@ -16,10 +15,8 @@ require (
15 )
16
17 require (
19 - github.com/inconshreveable/mousetrap v1.1.0 // indirect
18 github.com/mattn/go-colorable v0.1.14 // indirect
19 github.com/mattn/go-isatty v0.0.20 // indirect
22 - github.com/spf13/pflag v1.0.10 // indirect
20 golang.org/x/sys v0.37.0 // indirect
21 golang.org/x/text v0.30.0 // indirect
22 )