From ddb8df6cfcaf8715ec335c9b80944f7c7082227f Mon Sep 17 00:00:00 2001 From: gosunuts Date: Fri, 28 Aug 2026 16:48:40 +0900 Subject: [PATCH 1/4] fix(cli): print registered flags in --help Custom usage printers replaced FlagSet defaults, so expose and relay-server help listed examples and omitted identity-path, relays, api-port, and sni-port. Closes #351 --- cmd/portal-tunnel/main.go | 17 +++++++++++------ cmd/relay-server/main.go | 26 ++++++++++++++++---------- utils/cmd.go | 13 +++++++++++++ utils/cmd_usage_test.go | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 utils/cmd_usage_test.go diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index d021d702..c423bc27 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -80,12 +80,7 @@ type exposeFlags struct { metricsAddr string } -func runExposeCommand(args []string) error { - installer.StartUpdateCheck(types.ReleaseVersion) - - flags := exposeFlags{} - fs := utils.NewFlagSet("expose", printExposeUsage) - +func registerExposeFlags(fs *flag.FlagSet, flags *exposeFlags) { utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)") utils.StringFlagEnv(fs, &flags.multiHopCSV, "multi-hop", "", "Ordered multi-hop relay API URLs, comma-separated", "MULTI_HOP") utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include bootstrap relays and discover additional relays") @@ -113,7 +108,14 @@ func runExposeCommand(args []string) error { utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum auto-selected single-hop relays to keep connected; multi-hop uses every eligible relay as an entry", "MAX_ACTIVE_RELAYS") utils.IntFlagEnv(fs, &flags.multiHopDepth, "multi-hop-depth", 0, nil, "Automatically create multi-hop routes at this hop count for every eligible entry relay; 0 or 1 disables multi-hop", "MULTI_HOP_DEPTH") utils.StringFlag(fs, &flags.metricsAddr, "metrics-addr", "", "Optional address (host:port) to serve Prometheus /metrics. Empty = disabled.") +} + +func runExposeCommand(args []string) error { + installer.StartUpdateCheck(types.ReleaseVersion) + flags := exposeFlags{} + fs := utils.NewFlagSet("expose", printExposeUsage) + registerExposeFlags(fs, &flags) if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil { if errors.Is(err, flag.ErrHelp) { return nil @@ -435,6 +437,9 @@ func printExposeUsage(w io.Writer) { "portal expose 3000 --multi-hop-depth 3", }, ) + fs := utils.NewFlagSet("expose", nil) + registerExposeFlags(fs, &exposeFlags{}) + utils.WriteFlagDefaults(w, fs) } func printListUsage(w io.Writer) { diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index 0a814bec..88988e75 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -85,7 +85,20 @@ func resolveRelayServerConfig(args []string) (relayServerConfig, error) { cfg := relayServerConfig{} fs := utils.NewFlagSet("relay-server", printRootUsage) + registerRelayServerFlags(fs, &cfg) + if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil { + return relayServerConfig{}, err + } + if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil { + printRootUsage(os.Stderr) + return relayServerConfig{}, err + } + cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath) + return cfg, nil +} + +func registerRelayServerFlags(fs *flag.FlagSet, cfg *relayServerConfig) { utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost", "portal base URL", "PORTAL_URL") utils.StringFlagEnv(fs, &cfg.FrontendDir, "frontend-dir", "", "custom SPA directory containing index.html; embedded frontend is used when empty", "PORTAL_FRONTEND_DIR") utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, policy state, and keyless materials", "IDENTITY_PATH") @@ -126,16 +139,6 @@ func resolveRelayServerConfig(args []string) (relayServerConfig, error) { utils.StringFlagEnv(fs, &cfg.AWSDNSSECKMSKeyARN, "aws-dnssec-kms-key-arn", "", "AWS KMS key ARN used to create a Route53 DNSSEC key-signing key when needed", "AWS_DNSSEC_KMS_KEY_ARN") utils.StringFlagEnv(fs, &cfg.VultrAPIKey, "vultr-api-key", "", "Vultr API key for DNS automation (required when acme-dns-provider=vultr)", "VULTR_API_KEY") utils.StringFlagEnv(fs, &cfg.NjallaToken, "njalla-token", "", "Njalla API token for DNS automation (required when acme-dns-provider=njalla)", "NJALLA_TOKEN") - - if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil { - return relayServerConfig{}, err - } - if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil { - printRootUsage(os.Stderr) - return relayServerConfig{}, err - } - cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath) - return cfg, nil } func runServeCommand(args []string) error { @@ -275,4 +278,7 @@ func printRootUsage(w io.Writer) { "relay-server help", }, ) + fs := utils.NewFlagSet("relay-server", nil) + registerRelayServerFlags(fs, &relayServerConfig{}) + utils.WriteFlagDefaults(w, fs) } diff --git a/utils/cmd.go b/utils/cmd.go index 3ee5d2a0..948a00f3 100644 --- a/utils/cmd.go +++ b/utils/cmd.go @@ -523,3 +523,16 @@ func WriteCommandUsage(w io.Writer, usage []string, examples []string) { fmt.Fprintln(w, " "+strings.TrimSpace(line)) } } + +// WriteFlagDefaults prints the registered flags. Custom Usage printers replace +// FlagSet defaults, so --help would otherwise list examples and omit the flags +// agents need (identity-path, relays, api-port). +func WriteFlagDefaults(w io.Writer, fs *flag.FlagSet) { + if w == nil || fs == nil { + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Flags:") + fs.SetOutput(w) + fs.PrintDefaults() +} diff --git a/utils/cmd_usage_test.go b/utils/cmd_usage_test.go new file mode 100644 index 00000000..6470c081 --- /dev/null +++ b/utils/cmd_usage_test.go @@ -0,0 +1,33 @@ +package utils + +import ( + "bytes" + "strings" + "testing" +) + +func TestWriteFlagDefaultsListsRegisteredFlags(t *testing.T) { + var buf bytes.Buffer + var identityPath string + fs := NewFlagSet("expose", nil) + StringFlagEnv(fs, &identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH") + + WriteCommandUsage(&buf, []string{"portal expose [flags] "}, []string{"portal expose 3000"}) + WriteFlagDefaults(&buf, fs) + + got := buf.String() + for _, want := range []string{"Usage:", "Examples:", "Flags:", "-identity-path", "IDENTITY_PATH"} { + if !strings.Contains(got, want) { + t.Fatalf("help missing %q\n%s", want, got) + } + } +} + +func TestWriteFlagDefaultsNilIsNoop(t *testing.T) { + WriteFlagDefaults(nil, nil) + var buf bytes.Buffer + WriteFlagDefaults(&buf, nil) + if buf.Len() != 0 { + t.Fatalf("nil FlagSet should print nothing, got %q", buf.String()) + } +} From 1b6e09382221c206f9fba36f1f4e64f2313a3605 Mon Sep 17 00:00:00 2001 From: gosunuts Date: Fri, 28 Aug 2026 17:15:09 +0900 Subject: [PATCH 2/4] fix(cli): document loopback wiring in --help Flags were visible after the previous commit, but help still did not map api-port to --relays or name the service ready at log line. See also: #351 --- cmd/portal-tunnel/main.go | 6 ++++++ cmd/relay-server/main.go | 7 +++++++ utils/cmd.go | 11 +++++++++++ utils/cmd_usage_test.go | 22 ++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index c423bc27..cd8af7d7 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -440,6 +440,12 @@ func printExposeUsage(w io.Writer) { fs := utils.NewFlagSet("expose", nil) registerExposeFlags(fs, &exposeFlags{}) utils.WriteFlagDefaults(w, fs) + utils.WriteHelpSection(w, "Loopback", []string{ + "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", + }) + utils.WriteHelpSection(w, "Ready", []string{ + "On success the process logs a line starting with: service ready at", + }) } func printListUsage(w io.Writer) { diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index 88988e75..2bf33841 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -281,4 +281,11 @@ func printRootUsage(w io.Writer) { fs := utils.NewFlagSet("relay-server", nil) registerRelayServerFlags(fs, &relayServerConfig{}) utils.WriteFlagDefaults(w, fs) + utils.WriteHelpSection(w, "Loopback", []string{ + "relay-server --portal-url https://127.0.0.1:4017 --api-port 4017 --sni-port 8443", + "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", + }) + utils.WriteHelpSection(w, "Ready", []string{ + "On success the process logs a line starting with: service ready at", + }) } diff --git a/utils/cmd.go b/utils/cmd.go index 948a00f3..525c172d 100644 --- a/utils/cmd.go +++ b/utils/cmd.go @@ -536,3 +536,14 @@ func WriteFlagDefaults(w io.Writer, fs *flag.FlagSet) { fs.SetOutput(w) fs.PrintDefaults() } + +func WriteHelpSection(w io.Writer, heading string, lines []string) { + if w == nil || strings.TrimSpace(heading) == "" || len(lines) == 0 { + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, strings.TrimSpace(heading)+":") + for _, line := range lines { + fmt.Fprintln(w, " "+strings.TrimSpace(line)) + } +} diff --git a/utils/cmd_usage_test.go b/utils/cmd_usage_test.go index 6470c081..9e50d82c 100644 --- a/utils/cmd_usage_test.go +++ b/utils/cmd_usage_test.go @@ -31,3 +31,25 @@ func TestWriteFlagDefaultsNilIsNoop(t *testing.T) { t.Fatalf("nil FlagSet should print nothing, got %q", buf.String()) } } + +func TestWriteHelpSectionLoopbackAndReady(t *testing.T) { + var buf bytes.Buffer + WriteHelpSection(&buf, "Loopback", []string{ + "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", + }) + WriteHelpSection(&buf, "Ready", []string{ + "On success the process logs a line starting with: service ready at", + }) + got := buf.String() + for _, want := range []string{ + "Loopback:", + "--relays https://127.0.0.1:4017", + "--discovery=false", + "Ready:", + "service ready at", + } { + if !strings.Contains(got, want) { + t.Fatalf("help missing %q\n%s", want, got) + } + } +} From 4ae32f765ad58bd7af1c7cfb53696d7465385b6f Mon Sep 17 00:00:00 2001 From: rabbitprincess Date: Fri, 28 Aug 2026 23:32:05 +0900 Subject: [PATCH 3/4] fix(cli): keep environment values out of help --- cmd/portal-tunnel/main.go | 2 +- cmd/relay-server/main.go | 2 +- utils/cmd.go | 12 +++++++++--- utils/cmd_usage_test.go | 25 ++++++++++++++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index cd8af7d7..4f6236a7 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -441,7 +441,7 @@ func printExposeUsage(w io.Writer) { registerExposeFlags(fs, &exposeFlags{}) utils.WriteFlagDefaults(w, fs) utils.WriteHelpSection(w, "Loopback", []string{ - "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", + "portal expose 127.0.0.1:8080 --identity-path /absolute/path/outside/repo/identity.json --relays https://127.0.0.1:4017 --discovery=false", }) utils.WriteHelpSection(w, "Ready", []string{ "On success the process logs a line starting with: service ready at", diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index 2bf33841..55f089b1 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -286,6 +286,6 @@ func printRootUsage(w io.Writer) { "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", }) utils.WriteHelpSection(w, "Ready", []string{ - "On success the process logs a line starting with: service ready at", + "After portal expose succeeds, it logs a line starting with: service ready at", }) } diff --git a/utils/cmd.go b/utils/cmd.go index 525c172d..461bd8b0 100644 --- a/utils/cmd.go +++ b/utils/cmd.go @@ -224,7 +224,9 @@ func StringFlag(fs *flag.FlagSet, target *string, name, fallback, usage string) func StringFlagEnv(fs *flag.FlagSet, target *string, name, fallback, usage string, envNames ...string) { value, setBy := resolveStringEnv(fallback, envNames...) registerEnvVar(name, usage, fallback, value, setBy, envNames) - ensureFlagSet(fs).StringVar(target, name, value, flagUsage(usage, envNames...)) + flagSet := ensureFlagSet(fs) + flagSet.StringVar(target, name, value, flagUsage(usage, envNames...)) + flagSet.Lookup(name).DefValue = fallback } func BoolFlag(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string) { @@ -234,13 +236,17 @@ func BoolFlag(fs *flag.FlagSet, target *bool, name string, fallback bool, usage func BoolFlagEnv(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string, envNames ...string) { value, setBy := resolveBoolEnv(fallback, envNames...) registerEnvVar(name, usage, strconv.FormatBool(fallback), strconv.FormatBool(value), setBy, envNames) - ensureFlagSet(fs).BoolVar(target, name, value, flagUsage(usage, envNames...)) + flagSet := ensureFlagSet(fs) + flagSet.BoolVar(target, name, value, flagUsage(usage, envNames...)) + flagSet.Lookup(name).DefValue = strconv.FormatBool(fallback) } func IntFlagEnv(fs *flag.FlagSet, target *int, name string, fallback int, parse IntEnvParser, usage string, envNames ...string) { value, setBy := resolveIntEnv(fallback, parse, envNames...) registerEnvVar(name, usage, strconv.Itoa(fallback), strconv.Itoa(value), setBy, envNames) - ensureFlagSet(fs).IntVar(target, name, value, flagUsage(usage, envNames...)) + flagSet := ensureFlagSet(fs) + flagSet.IntVar(target, name, value, flagUsage(usage, envNames...)) + flagSet.Lookup(name).DefValue = strconv.Itoa(fallback) } func RepeatedStringFlag(fs *flag.FlagSet, target *[]string, name, usage string) { diff --git a/utils/cmd_usage_test.go b/utils/cmd_usage_test.go index 9e50d82c..763ed925 100644 --- a/utils/cmd_usage_test.go +++ b/utils/cmd_usage_test.go @@ -7,20 +7,38 @@ import ( ) func TestWriteFlagDefaultsListsRegisteredFlags(t *testing.T) { + ResetEnvRegistry() + t.Cleanup(ResetEnvRegistry) + t.Setenv("IDENTITY_PATH", "/private/runtime/identity.json") + t.Setenv("ADMIN_TOKEN", "admin-token-secret") + t.Setenv("AWS_SECRET_ACCESS_KEY", "aws-secret-access-key") + var buf bytes.Buffer var identityPath string + var adminToken string + var awsSecretAccessKey string fs := NewFlagSet("expose", nil) StringFlagEnv(fs, &identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH") + StringFlagEnv(fs, &adminToken, "admin-token", "", "admin bearer token", "ADMIN_TOKEN") + StringFlagEnv(fs, &awsSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key", "AWS_SECRET_ACCESS_KEY") + if identityPath != "/private/runtime/identity.json" || adminToken != "admin-token-secret" || awsSecretAccessKey != "aws-secret-access-key" { + t.Fatal("environment values were not resolved into flag targets") + } WriteCommandUsage(&buf, []string{"portal expose [flags] "}, []string{"portal expose 3000"}) WriteFlagDefaults(&buf, fs) got := buf.String() - for _, want := range []string{"Usage:", "Examples:", "Flags:", "-identity-path", "IDENTITY_PATH"} { + for _, want := range []string{"Usage:", "Examples:", "Flags:", "-identity-path", "identity.json", "IDENTITY_PATH"} { if !strings.Contains(got, want) { t.Fatalf("help missing %q\n%s", want, got) } } + for _, secret := range []string{"/private/runtime/identity.json", "admin-token-secret", "aws-secret-access-key"} { + if strings.Contains(got, secret) { + t.Fatalf("help contains environment value %q\n%s", secret, got) + } + } } func TestWriteFlagDefaultsNilIsNoop(t *testing.T) { @@ -35,14 +53,15 @@ func TestWriteFlagDefaultsNilIsNoop(t *testing.T) { func TestWriteHelpSectionLoopbackAndReady(t *testing.T) { var buf bytes.Buffer WriteHelpSection(&buf, "Loopback", []string{ - "portal expose 127.0.0.1:8080 --relays https://127.0.0.1:4017 --discovery=false", + "portal expose 127.0.0.1:8080 --identity-path /absolute/path/outside/repo/identity.json --relays https://127.0.0.1:4017 --discovery=false", }) WriteHelpSection(&buf, "Ready", []string{ - "On success the process logs a line starting with: service ready at", + "After portal expose succeeds, it logs a line starting with: service ready at", }) got := buf.String() for _, want := range []string{ "Loopback:", + "--identity-path /absolute/path/outside/repo/identity.json", "--relays https://127.0.0.1:4017", "--discovery=false", "Ready:", From 2e02cce1cc2a9f6a5449fb23a2fffe7e6d492b2a Mon Sep 17 00:00:00 2001 From: rabbitprincess Date: Fri, 28 Aug 2026 23:45:28 +0900 Subject: [PATCH 4/4] test(cli): distinguish secret values from flag names --- utils/cmd_usage_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/cmd_usage_test.go b/utils/cmd_usage_test.go index 763ed925..2358a45c 100644 --- a/utils/cmd_usage_test.go +++ b/utils/cmd_usage_test.go @@ -11,7 +11,7 @@ func TestWriteFlagDefaultsListsRegisteredFlags(t *testing.T) { t.Cleanup(ResetEnvRegistry) t.Setenv("IDENTITY_PATH", "/private/runtime/identity.json") t.Setenv("ADMIN_TOKEN", "admin-token-secret") - t.Setenv("AWS_SECRET_ACCESS_KEY", "aws-secret-access-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "runtime-aws-credential") var buf bytes.Buffer var identityPath string @@ -21,7 +21,7 @@ func TestWriteFlagDefaultsListsRegisteredFlags(t *testing.T) { StringFlagEnv(fs, &identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH") StringFlagEnv(fs, &adminToken, "admin-token", "", "admin bearer token", "ADMIN_TOKEN") StringFlagEnv(fs, &awsSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key", "AWS_SECRET_ACCESS_KEY") - if identityPath != "/private/runtime/identity.json" || adminToken != "admin-token-secret" || awsSecretAccessKey != "aws-secret-access-key" { + if identityPath != "/private/runtime/identity.json" || adminToken != "admin-token-secret" || awsSecretAccessKey != "runtime-aws-credential" { t.Fatal("environment values were not resolved into flag targets") } @@ -34,7 +34,7 @@ func TestWriteFlagDefaultsListsRegisteredFlags(t *testing.T) { t.Fatalf("help missing %q\n%s", want, got) } } - for _, secret := range []string{"/private/runtime/identity.json", "admin-token-secret", "aws-secret-access-key"} { + for _, secret := range []string{"/private/runtime/identity.json", "admin-token-secret", "runtime-aws-credential"} { if strings.Contains(got, secret) { t.Fatalf("help contains environment value %q\n%s", secret, got) }