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
)