From d9af955feb7838a489fe166e209f3ec220dba35a Mon Sep 17 00:00:00 2001 From: sdp <5931987+prabowosd@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:40:42 +0700 Subject: [PATCH 1/4] feat(share): serve on a custom Cloudflare-managed domain via --domain --- internal/cli/share.go | 91 +++++++++++++++++-- internal/cli/share_test.go | 181 ++++++++++++++++++++++++++++++++++--- 2 files changed, 251 insertions(+), 21 deletions(-) diff --git a/internal/cli/share.go b/internal/cli/share.go index fe8e2580c..63c849fdb 100644 --- a/internal/cli/share.go +++ b/internal/cli/share.go @@ -28,6 +28,7 @@ func NewShareCmd() *cobra.Command { var useExpose bool var useServeo bool var useLocalhostRun bool + var domain string cmd := &cobra.Command{ Use: "share [site]", @@ -41,10 +42,14 @@ Supported tools: cloudflared https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ expose https://expose.dev localhost.run free SSH tunnel, no account needed (--localhost-run) - serveo.net free SSH tunnel, no account needed (--serveo)`, + serveo.net free SSH tunnel, no account needed (--serveo) + +With --domain, a named Cloudflare Tunnel is created (or reused) and the given +hostname is routed to it, so the site is served on your own domain instead of a +random trycloudflare.com URL. The domain's DNS must be managed by Cloudflare.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - return runShare(args, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun) + return runShare(args, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain) }, } @@ -53,6 +58,7 @@ Supported tools: cmd.Flags().BoolVar(&useExpose, "expose", false, "Use Expose") cmd.Flags().BoolVar(&useServeo, "serveo", false, "Use serveo.net (SSH, no signup)") cmd.Flags().BoolVar(&useLocalhostRun, "localhost-run", false, "Use localhost.run (SSH, no signup)") + cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (implies --cloudflare)") return cmd } @@ -68,9 +74,10 @@ const ( type shareTool struct { mode shareMode sshHost string // only for shareModeSSH + domain string // only for shareModeCloudflare: user's own hostname (named tunnel) } -func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool) error { +func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain string) error { site, err := resolveShareSite(args) if err != nil { return err @@ -85,7 +92,7 @@ func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useL port = 80 } - tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun) + tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain) if err != nil { return err } @@ -115,8 +122,18 @@ func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useL return fmt.Errorf("starting local proxy: %w", err) } defer stop() - cmd = exec.Command("cloudflared", "tunnel", - "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort)) + if tool.domain != "" { + tunnelName, err := ensureCloudflareTunnel(site.Name, tool.domain) + if err != nil { + return err + } + fmt.Printf("Public URL: https://%s\n\n", tool.domain) + cmd = exec.Command("cloudflared", "tunnel", "run", + "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort), tunnelName) + } else { + cmd = exec.Command("cloudflared", "tunnel", + "--url", fmt.Sprintf("http://127.0.0.1:%d", proxyPort)) + } case shareModeSSH: httpsPort := cfg.Nginx.HTTPSPort if httpsPort == 0 { @@ -173,7 +190,7 @@ func resolveShareSite(args []string) (*config.Site, error) { return ensureSiteForCwd() } -func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool) (*shareTool, error) { +func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain string) (*shareTool, error) { count := 0 for _, f := range []bool{useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun} { if f { @@ -184,6 +201,16 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu return nil, fmt.Errorf("only one of --ngrok, --cloudflare, --expose, --serveo, --localhost-run may be specified") } + if domain != "" { + if useNgrok || useExpose || useServeo || useLocalhostRun { + return nil, fmt.Errorf("--domain only works with Cloudflare Tunnel: drop the other tunnel flag") + } + if _, err := exec.LookPath("cloudflared"); err != nil { + return nil, fmt.Errorf("cloudflared not found in PATH, install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + } + return &shareTool{mode: shareModeCloudflare, domain: domain}, nil + } + if useNgrok { if _, err := exec.LookPath("ngrok"); err != nil { return nil, fmt.Errorf("ngrok not found in PATH — install it from https://ngrok.com/download") @@ -227,6 +254,56 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu return nil, fmt.Errorf("no tunnel tool found — install ngrok (https://ngrok.com/download), cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), or Expose (https://expose.dev), or ensure ssh is in PATH") } +// cloudflaredCertPath returns the origin certificate cloudflared writes after +// "tunnel login". TUNNEL_ORIGIN_CERT overrides it, mirroring cloudflared itself. +func cloudflaredCertPath() string { + if p := os.Getenv("TUNNEL_ORIGIN_CERT"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cloudflared", "cert.pem") +} + +// ensureCloudflareTunnel makes sure a named tunnel exists for the site and that +// domain routes to it, logging in interactively first if needed. +// Returns the tunnel name to pass to "cloudflared tunnel run". +func ensureCloudflareTunnel(siteName, domain string) (string, error) { + if _, err := os.Stat(cloudflaredCertPath()); err != nil { + fmt.Println("cloudflared is not logged in yet, a browser window will open to authorize it.") + login := exec.Command("cloudflared", "tunnel", "login") + login.Stdin = os.Stdin + login.Stdout = os.Stdout + login.Stderr = os.Stderr + if err := login.Run(); err != nil { + return "", fmt.Errorf("cloudflared tunnel login: %w", err) + } + if _, err := os.Stat(cloudflaredCertPath()); err != nil { + return "", fmt.Errorf("cloudflared login finished but %s is still missing", cloudflaredCertPath()) + } + } + + name := "lerd-" + siteName + out, err := exec.Command("cloudflared", "tunnel", "create", name).CombinedOutput() + if err != nil && !strings.Contains(string(out), "already exists") { + return "", fmt.Errorf("cloudflared tunnel create %s: %w\n%s", name, err, out) + } + + // Route DNS is not idempotent: cloudflared refuses to touch an existing + // record, so tolerate that and let the user verify it points at this tunnel. + out, err = exec.Command("cloudflared", "tunnel", "route", "dns", name, domain).CombinedOutput() + if err != nil { + if strings.Contains(string(out), "already exists") || strings.Contains(string(out), "already configured") { + fmt.Printf("DNS record for %s already exists, make sure it CNAMEs to tunnel %q.\n", domain, name) + } else { + return "", fmt.Errorf("cloudflared tunnel route dns %s %s: %w\n%s", name, domain, err, out) + } + } + return name, nil +} + // startHostProxy starts a local HTTP reverse proxy on a random loopback port. // It rewrites the Host header to domain before forwarding to nginx, // so nginx can route the request to the correct vhost. diff --git a/internal/cli/share_test.go b/internal/cli/share_test.go index 59fe2c541..a0035b9c7 100644 --- a/internal/cli/share_test.go +++ b/internal/cli/share_test.go @@ -66,7 +66,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { {true, false, false, false, true}, // ngrok + localhost-run } for _, p := range pairs { - _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4]) + _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4], "") if err == nil { t.Errorf("pickShareTool%v: expected mutual-exclusion error, got nil", p) continue @@ -79,7 +79,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { func TestPickShareTool_explicitNgrok_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok")) - tool, err := pickShareTool(true, false, false, false, false) + tool, err := pickShareTool(true, false, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,7 +90,7 @@ func TestPickShareTool_explicitNgrok_present(t *testing.T) { func TestPickShareTool_explicitNgrok_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(true, false, false, false, false) + _, err := pickShareTool(true, false, false, false, false, "") if err == nil { t.Fatal("expected error, got nil") } @@ -101,7 +101,7 @@ func TestPickShareTool_explicitNgrok_absent(t *testing.T) { func TestPickShareTool_explicitCloudflare_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared")) - tool, err := pickShareTool(false, true, false, false, false) + tool, err := pickShareTool(false, true, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -112,7 +112,7 @@ func TestPickShareTool_explicitCloudflare_present(t *testing.T) { func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, true, false, false, false) + _, err := pickShareTool(false, true, false, false, false, "") if err == nil { t.Fatal("expected error, got nil") } @@ -123,7 +123,7 @@ func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { func TestPickShareTool_explicitExpose_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose")) - tool, err := pickShareTool(false, false, true, false, false) + tool, err := pickShareTool(false, false, true, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -134,7 +134,7 @@ func TestPickShareTool_explicitExpose_present(t *testing.T) { func TestPickShareTool_explicitExpose_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, true, false, false) + _, err := pickShareTool(false, false, true, false, false, "") if err == nil { t.Fatal("expected error, got nil") } @@ -144,7 +144,7 @@ func TestPickShareTool_explicitExpose_absent(t *testing.T) { } func TestPickShareTool_explicitServeo(t *testing.T) { - tool, err := pickShareTool(false, false, false, true, false) + tool, err := pickShareTool(false, false, false, true, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -157,7 +157,7 @@ func TestPickShareTool_explicitServeo(t *testing.T) { } func TestPickShareTool_explicitLocalhostRun(t *testing.T) { - tool, err := pickShareTool(false, false, false, false, true) + tool, err := pickShareTool(false, false, false, false, true, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -171,7 +171,7 @@ func TestPickShareTool_explicitLocalhostRun(t *testing.T) { func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -182,7 +182,7 @@ func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -193,7 +193,7 @@ func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { func TestPickShareTool_autoDetect_expose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -204,7 +204,7 @@ func TestPickShareTool_autoDetect_expose(t *testing.T) { func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ssh")) - tool, err := pickShareTool(false, false, false, false, false) + tool, err := pickShareTool(false, false, false, false, false, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -218,7 +218,7 @@ func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { func TestPickShareTool_autoDetect_noTools(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, false, false, false) + _, err := pickShareTool(false, false, false, false, false, "") if err == nil { t.Fatal("expected error, got nil") } @@ -227,6 +227,159 @@ func TestPickShareTool_autoDetect_noTools(t *testing.T) { } } +func TestPickShareTool_domain_impliesCloudflare(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(false, false, false, false, false, "dev.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeCloudflare { + t.Errorf("mode = %v, want shareModeCloudflare", tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("domain = %q, want dev.example.com", tool.domain) + } +} + +func TestPickShareTool_domain_withOtherTool(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + for _, p := range [][4]bool{ + {true, false, false, false}, + {false, true, false, false}, + {false, false, true, false}, + {false, false, false, true}, + } { + _, err := pickShareTool(p[0], false, p[1], p[2], p[3], "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "--domain only works with Cloudflare") { + t.Errorf("pickShareTool%v: error = %v, want '--domain only works with Cloudflare'", p, err) + } + } +} + +func TestPickShareTool_domain_cloudflaredAbsent(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := pickShareTool(false, true, false, false, false, "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "cloudflared not found") { + t.Errorf("error = %v, want 'cloudflared not found'", err) + } +} + +// ── ensureCloudflareTunnel ──────────────────────────────────────────────────── + +// fakeCloudflared installs a fake cloudflared script and returns its log file. +// The script logs every invocation; createExit/routeExit control the exit codes +// and output of "tunnel create" / "tunnel route dns". "tunnel login" writes +// cert.pem under $HOME/.cloudflared. +func fakeCloudflared(t *testing.T, createOut string, createExit int, routeOut string, routeExit int) string { + t.Helper() + dir := t.TempDir() + logFile := filepath.Join(dir, "calls.log") + script := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +case "$1 $2" in +"tunnel login") : > "$HOME/.cloudflared/cert.pem";; +"tunnel create") echo %q; exit %d;; +"tunnel route") echo %q; exit %d;; +esac +`, logFile, createOut, createExit, routeOut, routeExit) + if err := os.WriteFile(filepath.Join(dir, "cloudflared"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + return logFile +} + +func readCalls(t *testing.T, logFile string) string { + t.Helper() + b, _ := os.ReadFile(logFile) + return string(b) +} + +func TestEnsureCloudflareTunnel_happyPath_logsInWhenNoCert(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TUNNEL_ORIGIN_CERT", "") + if err := os.MkdirAll(filepath.Join(home, ".cloudflared"), 0755); err != nil { + t.Fatal(err) + } + logFile := fakeCloudflared(t, "created", 0, "routed", 0) + + name, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "lerd-mysite" { + t.Errorf("name = %q, want lerd-mysite", name) + } + calls := readCalls(t, logFile) + for _, want := range []string{"tunnel login", "tunnel create lerd-mysite", "tunnel route dns lerd-mysite dev.example.com"} { + if !strings.Contains(calls, want) { + t.Errorf("calls %q missing %q", calls, want) + } + } +} + +func TestEnsureCloudflareTunnel_skipsLoginWhenCertExists(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + logFile := fakeCloudflared(t, "created", 0, "routed", 0) + + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(readCalls(t, logFile), "tunnel login") { + t.Error("tunnel login was called despite existing cert") + } +} + +func TestEnsureCloudflareTunnel_toleratesExistingTunnelAndRoute(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + fakeCloudflared(t, "tunnel with name already exists", 1, "record with that host already exists", 1) + + name, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "lerd-mysite" { + t.Errorf("name = %q, want lerd-mysite", name) + } +} + +func TestEnsureCloudflareTunnel_createFailurePropagates(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + fakeCloudflared(t, "auth error", 1, "routed", 0) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "tunnel create") { + t.Errorf("error = %v, want tunnel create failure", err) + } +} + +func TestEnsureCloudflareTunnel_routeFailurePropagates(t *testing.T) { + cert := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", cert) + fakeCloudflared(t, "created", 0, "zone not found", 1) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil || !strings.Contains(err.Error(), "route dns") { + t.Errorf("error = %v, want route dns failure", err) + } +} + // ── startHostProxy ──────────────────────────────────────────────────────────── // proxyPort parses the port from a URL string like "http://127.0.0.1:PORT". From c2aae40ba287e90ab75978096674021fc2b51ef9 Mon Sep 17 00:00:00 2001 From: sdp <5931987+prabowosd@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:04:44 +0700 Subject: [PATCH 2/4] docs(share): document --domain custom-hostname sharing --- docs/reference/commands.md | 1 + docs/usage/sites.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index dd5eb0e04..1cf50c1e6 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,6 +69,7 @@ Setup steps include common tasks (composer install, npm install, lerd env) plus | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | | `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | +| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (implies `--cloudflare`) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd secure --renew [name]` | Reissue a secured site's TLS cert on demand, resetting its expiry | | `lerd unsecure [name]` | Remove TLS and switch back to HTTP, updates `APP_URL` in `.env` | diff --git a/docs/usage/sites.md b/docs/usage/sites.md index f9d9a8cdd..0b6fd8480 100644 --- a/docs/usage/sites.md +++ b/docs/usage/sites.md @@ -403,6 +403,17 @@ Lerd automatically creates a subdomain for each `git worktree` checkout. See [Gi | `lerd share --expose` | Force Expose | | `lerd share --localhost-run` | Force localhost.run (SSH, no signup) | | `lerd share --serveo` | Force serveo.net (SSH, no signup) | +| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (implies `--cloudflare`) | + +### Sharing on your own domain + +Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare: + +```bash +lerd share --domain dev.example.com +``` + +On the first run cloudflared opens a browser window to authorize your Cloudflare account (a one-time login that writes `~/.cloudflared/cert.pem`). lerd then creates a named tunnel called `lerd-`, routes the hostname to it with a CNAME record, and starts the tunnel. Later runs reuse the same tunnel and hostname, so the URL never changes. If a DNS record for the hostname already exists, lerd leaves it alone and prints a note asking you to check that it points at the tunnel. A local reverse proxy rewrites the `Host` header to the site's domain so nginx routes to the correct vhost. Response `Location` headers and HTML/CSS/JS/JSON body references to the local domain are also rewritten to the public tunnel URL, so redirects and asset links work correctly in the browser. From 5afac226292449460488e3aed1a995a4d9f35a1a Mon Sep 17 00:00:00 2001 From: sdp <5931987+prabowosd@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:13:00 +0700 Subject: [PATCH 3/4] feat(share): configurable default tunnel tool via share:tool, gate --domain on it --- cmd/lerd/main.go | 1 + docs/reference/commands.md | 3 +- docs/usage/sites.md | 10 +++- internal/cli/share.go | 43 +++++++++----- internal/cli/share_test.go | 112 ++++++++++++++++++++++++++++++------- internal/cli/share_tool.go | 79 ++++++++++++++++++++++++++ internal/config/global.go | 6 ++ 7 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 internal/cli/share_tool.go diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go index ab4dfd249..cc176ecbc 100644 --- a/cmd/lerd/main.go +++ b/cmd/lerd/main.go @@ -208,6 +208,7 @@ func main() { root.AddCommand(cmd) } root.AddCommand(cli.NewShareCmd()) + root.AddCommand(cli.NewShareToolCmd()) root.AddCommand(cli.NewDomainCmd()) root.AddCommand(cli.NewGroupCmd()) root.AddCommand(cli.NewWorkspaceCmd()) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 1cf50c1e6..d5b3fe0c8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,7 +69,8 @@ Setup steps include common tasks (composer install, npm install, lerd env) plus | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | | `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | -| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (implies `--cloudflare`) | +| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (Cloudflare Tunnel only) | +| `lerd share:tool [tool]` | Show or set the default tunnel tool for `lerd share` (`auto` restores auto-detection) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd secure --renew [name]` | Reissue a secured site's TLS cert on demand, resetting its expiry | | `lerd unsecure [name]` | Remove TLS and switch back to HTTP, updates `APP_URL` in `.env` | diff --git a/docs/usage/sites.md b/docs/usage/sites.md index 0b6fd8480..b1045e27b 100644 --- a/docs/usage/sites.md +++ b/docs/usage/sites.md @@ -403,13 +403,19 @@ Lerd automatically creates a subdomain for each `git worktree` checkout. See [Gi | `lerd share --expose` | Force Expose | | `lerd share --localhost-run` | Force localhost.run (SSH, no signup) | | `lerd share --serveo` | Force serveo.net (SSH, no signup) | -| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (implies `--cloudflare`) | +| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (Cloudflare Tunnel only) | +| `lerd share:tool [tool]` | Show or set the default tunnel tool (`ngrok`, `cloudflare`, `expose`, `serveo`, `localhost-run`, or `auto`) | + +### Default tunnel tool + +Auto-detection picks the first installed tool, which may not be the one you want. `lerd share:tool cloudflare` pins the default; from then on a bare `lerd share` uses Cloudflare Tunnel even with ngrok installed. A tool flag still overrides the default per run, and `lerd share:tool auto` restores auto-detection. ### Sharing on your own domain -Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare: +Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare. It only applies to Cloudflare Tunnel, so pass `--cloudflare` or set it as your default tool first: ```bash +lerd share:tool cloudflare lerd share --domain dev.example.com ``` diff --git a/internal/cli/share.go b/internal/cli/share.go index 63c849fdb..5b5162bad 100644 --- a/internal/cli/share.go +++ b/internal/cli/share.go @@ -44,9 +44,12 @@ Supported tools: localhost.run free SSH tunnel, no account needed (--localhost-run) serveo.net free SSH tunnel, no account needed (--serveo) -With --domain, a named Cloudflare Tunnel is created (or reused) and the given -hostname is routed to it, so the site is served on your own domain instead of a -random trycloudflare.com URL. The domain's DNS must be managed by Cloudflare.`, +A default tool can be set with "lerd share:tool"; flags override it per run. + +With --domain (Cloudflare Tunnel only), a named tunnel is created (or reused) +and the given hostname is routed to it, so the site is served on your own +domain instead of a random trycloudflare.com URL. The domain's DNS must be +managed by Cloudflare.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { return runShare(args, useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain) @@ -58,7 +61,7 @@ random trycloudflare.com URL. The domain's DNS must be managed by Cloudflare.`, cmd.Flags().BoolVar(&useExpose, "expose", false, "Use Expose") cmd.Flags().BoolVar(&useServeo, "serveo", false, "Use serveo.net (SSH, no signup)") cmd.Flags().BoolVar(&useLocalhostRun, "localhost-run", false, "Use localhost.run (SSH, no signup)") - cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (implies --cloudflare)") + cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (Cloudflare Tunnel only)") return cmd } @@ -92,7 +95,7 @@ func runShare(args []string, useNgrok, useCloudflare, useExpose, useServeo, useL port = 80 } - tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain) + tool, err := pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun, domain, cfg.Share.DefaultTool) if err != nil { return err } @@ -190,7 +193,7 @@ func resolveShareSite(args []string) (*config.Site, error) { return ensureSiteForCwd() } -func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain string) (*shareTool, error) { +func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun bool, domain, defaultTool string) (*shareTool, error) { count := 0 for _, f := range []bool{useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRun} { if f { @@ -201,14 +204,26 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu return nil, fmt.Errorf("only one of --ngrok, --cloudflare, --expose, --serveo, --localhost-run may be specified") } - if domain != "" { - if useNgrok || useExpose || useServeo || useLocalhostRun { - return nil, fmt.Errorf("--domain only works with Cloudflare Tunnel: drop the other tunnel flag") - } - if _, err := exec.LookPath("cloudflared"); err != nil { - return nil, fmt.Errorf("cloudflared not found in PATH, install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + // No tool flag: fall back to the configured default before auto-detecting. + if count == 0 && defaultTool != "" { + switch defaultTool { + case "ngrok": + useNgrok = true + case "cloudflare": + useCloudflare = true + case "expose": + useExpose = true + case "serveo": + useServeo = true + case "localhost-run": + useLocalhostRun = true + default: + return nil, fmt.Errorf("unknown default share tool %q in config: run \"lerd share:tool\" to fix it", defaultTool) } - return &shareTool{mode: shareModeCloudflare, domain: domain}, nil + } + + if domain != "" && !useCloudflare { + return nil, fmt.Errorf("--domain needs Cloudflare Tunnel: pass --cloudflare or set it as the default with \"lerd share:tool cloudflare\"") } if useNgrok { @@ -221,7 +236,7 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu if _, err := exec.LookPath("cloudflared"); err != nil { return nil, fmt.Errorf("cloudflared not found in PATH — install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") } - return &shareTool{mode: shareModeCloudflare}, nil + return &shareTool{mode: shareModeCloudflare, domain: domain}, nil } if useExpose { if _, err := exec.LookPath("expose"); err != nil { diff --git a/internal/cli/share_test.go b/internal/cli/share_test.go index a0035b9c7..8c4b5aa0c 100644 --- a/internal/cli/share_test.go +++ b/internal/cli/share_test.go @@ -66,7 +66,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { {true, false, false, false, true}, // ngrok + localhost-run } for _, p := range pairs { - _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4], "") + _, err := pickShareTool(p[0], p[1], p[2], p[3], p[4], "", "") if err == nil { t.Errorf("pickShareTool%v: expected mutual-exclusion error, got nil", p) continue @@ -79,7 +79,7 @@ func TestPickShareTool_mutualExclusion(t *testing.T) { func TestPickShareTool_explicitNgrok_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok")) - tool, err := pickShareTool(true, false, false, false, false, "") + tool, err := pickShareTool(true, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,7 +90,7 @@ func TestPickShareTool_explicitNgrok_present(t *testing.T) { func TestPickShareTool_explicitNgrok_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(true, false, false, false, false, "") + _, err := pickShareTool(true, false, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -101,7 +101,7 @@ func TestPickShareTool_explicitNgrok_absent(t *testing.T) { func TestPickShareTool_explicitCloudflare_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared")) - tool, err := pickShareTool(false, true, false, false, false, "") + tool, err := pickShareTool(false, true, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -112,7 +112,7 @@ func TestPickShareTool_explicitCloudflare_present(t *testing.T) { func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, true, false, false, false, "") + _, err := pickShareTool(false, true, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -123,7 +123,7 @@ func TestPickShareTool_explicitCloudflare_absent(t *testing.T) { func TestPickShareTool_explicitExpose_present(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose")) - tool, err := pickShareTool(false, false, true, false, false, "") + tool, err := pickShareTool(false, false, true, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -134,7 +134,7 @@ func TestPickShareTool_explicitExpose_present(t *testing.T) { func TestPickShareTool_explicitExpose_absent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, true, false, false, "") + _, err := pickShareTool(false, false, true, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -144,7 +144,7 @@ func TestPickShareTool_explicitExpose_absent(t *testing.T) { } func TestPickShareTool_explicitServeo(t *testing.T) { - tool, err := pickShareTool(false, false, false, true, false, "") + tool, err := pickShareTool(false, false, false, true, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -157,7 +157,7 @@ func TestPickShareTool_explicitServeo(t *testing.T) { } func TestPickShareTool_explicitLocalhostRun(t *testing.T) { - tool, err := pickShareTool(false, false, false, false, true, "") + tool, err := pickShareTool(false, false, false, false, true, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -171,7 +171,7 @@ func TestPickShareTool_explicitLocalhostRun(t *testing.T) { func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false, "") + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -182,7 +182,7 @@ func TestPickShareTool_autoDetect_ngrokFirst(t *testing.T) { func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "cloudflared", "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false, "") + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -193,7 +193,7 @@ func TestPickShareTool_autoDetect_cloudflareBeforeExpose(t *testing.T) { func TestPickShareTool_autoDetect_expose(t *testing.T) { t.Setenv("PATH", fakeBin(t, "expose", "ssh")) - tool, err := pickShareTool(false, false, false, false, false, "") + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -204,7 +204,7 @@ func TestPickShareTool_autoDetect_expose(t *testing.T) { func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ssh")) - tool, err := pickShareTool(false, false, false, false, false, "") + tool, err := pickShareTool(false, false, false, false, false, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -218,7 +218,7 @@ func TestPickShareTool_autoDetect_sshFallback(t *testing.T) { func TestPickShareTool_autoDetect_noTools(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, false, false, false, false, "") + _, err := pickShareTool(false, false, false, false, false, "", "") if err == nil { t.Fatal("expected error, got nil") } @@ -227,9 +227,9 @@ func TestPickShareTool_autoDetect_noTools(t *testing.T) { } } -func TestPickShareTool_domain_impliesCloudflare(t *testing.T) { +func TestPickShareTool_domain_withExplicitCloudflare(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) - tool, err := pickShareTool(false, false, false, false, false, "dev.example.com") + tool, err := pickShareTool(false, true, false, false, false, "dev.example.com", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -241,6 +241,31 @@ func TestPickShareTool_domain_impliesCloudflare(t *testing.T) { } } +func TestPickShareTool_domain_withCloudflareDefault(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(false, false, false, false, false, "dev.example.com", "cloudflare") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeCloudflare { + t.Errorf("mode = %v, want shareModeCloudflare", tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("domain = %q, want dev.example.com", tool.domain) + } +} + +func TestPickShareTool_domain_withoutCloudflare(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + // No tool flag and no cloudflare default: --domain must not pick a tool itself. + for _, defaultTool := range []string{"", "ngrok"} { + _, err := pickShareTool(false, false, false, false, false, "dev.example.com", defaultTool) + if err == nil || !strings.Contains(err.Error(), "--domain needs Cloudflare Tunnel") { + t.Errorf("default %q: error = %v, want '--domain needs Cloudflare Tunnel'", defaultTool, err) + } + } +} + func TestPickShareTool_domain_withOtherTool(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) for _, p := range [][4]bool{ @@ -249,21 +274,68 @@ func TestPickShareTool_domain_withOtherTool(t *testing.T) { {false, false, true, false}, {false, false, false, true}, } { - _, err := pickShareTool(p[0], false, p[1], p[2], p[3], "dev.example.com") - if err == nil || !strings.Contains(err.Error(), "--domain only works with Cloudflare") { - t.Errorf("pickShareTool%v: error = %v, want '--domain only works with Cloudflare'", p, err) + _, err := pickShareTool(p[0], false, p[1], p[2], p[3], "dev.example.com", "") + if err == nil || !strings.Contains(err.Error(), "--domain needs Cloudflare Tunnel") { + t.Errorf("pickShareTool%v: error = %v, want '--domain needs Cloudflare Tunnel'", p, err) } } } func TestPickShareTool_domain_cloudflaredAbsent(t *testing.T) { t.Setenv("PATH", t.TempDir()) - _, err := pickShareTool(false, true, false, false, false, "dev.example.com") + _, err := pickShareTool(false, true, false, false, false, "dev.example.com", "") if err == nil || !strings.Contains(err.Error(), "cloudflared not found") { t.Errorf("error = %v, want 'cloudflared not found'", err) } } +func TestPickShareTool_defaultTool_used(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared", "expose", "ssh")) + cases := []struct { + defaultTool string + wantMode shareMode + wantSSHHost string + }{ + {"ngrok", shareModeNgrok, ""}, + {"cloudflare", shareModeCloudflare, ""}, + {"expose", shareModeExpose, ""}, + {"serveo", shareModeSSH, "serveo.net"}, + {"localhost-run", shareModeSSH, "localhost.run"}, + } + for _, c := range cases { + tool, err := pickShareTool(false, false, false, false, false, "", c.defaultTool) + if err != nil { + t.Errorf("default %q: unexpected error: %v", c.defaultTool, err) + continue + } + if tool.mode != c.wantMode { + t.Errorf("default %q: mode = %v, want %v", c.defaultTool, tool.mode, c.wantMode) + } + if tool.sshHost != c.wantSSHHost { + t.Errorf("default %q: sshHost = %q, want %q", c.defaultTool, tool.sshHost, c.wantSSHHost) + } + } +} + +func TestPickShareTool_defaultTool_flagOverrides(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) + tool, err := pickShareTool(true, false, false, false, false, "", "cloudflare") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tool.mode != shareModeNgrok { + t.Errorf("mode = %v, want shareModeNgrok (flag beats default)", tool.mode) + } +} + +func TestPickShareTool_defaultTool_unknown(t *testing.T) { + t.Setenv("PATH", fakeBin(t, "ngrok")) + _, err := pickShareTool(false, false, false, false, false, "", "bogus") + if err == nil || !strings.Contains(err.Error(), "unknown default share tool") { + t.Errorf("error = %v, want 'unknown default share tool'", err) + } +} + // ── ensureCloudflareTunnel ──────────────────────────────────────────────────── // fakeCloudflared installs a fake cloudflared script and returns its log file. diff --git a/internal/cli/share_tool.go b/internal/cli/share_tool.go new file mode 100644 index 000000000..7c6e249c2 --- /dev/null +++ b/internal/cli/share_tool.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/geodro/lerd/internal/config" + "github.com/geodro/lerd/internal/feedback" + "github.com/spf13/cobra" +) + +// shareToolBinaries maps a default-tool name to the binary it needs in PATH. +// SSH-based tools need no dedicated binary beyond ssh itself. +var shareToolBinaries = map[string]string{ + "ngrok": "ngrok", + "cloudflare": "cloudflared", + "expose": "expose", + "serveo": "ssh", + "localhost-run": "ssh", +} + +// NewShareToolCmd returns the share:tool command. +func NewShareToolCmd() *cobra.Command { + return &cobra.Command{ + Use: "share:tool [ngrok|cloudflare|expose|serveo|localhost-run|auto]", + Short: "Show or set the default tunnel tool for lerd share", + Long: `Without an argument, prints the current default tunnel tool. + +With an argument, sets the tool "lerd share" uses when no tool flag is passed. +"auto" clears the default and restores auto-detection +(ngrok, then cloudflared, then Expose, then localhost.run).`, + Args: cobra.MaximumNArgs(1), + RunE: runShareTool, + } +} + +func runShareTool(_ *cobra.Command, args []string) error { + cfg, err := config.LoadGlobal() + if err != nil { + return err + } + + if len(args) == 0 { + if cfg.Share.DefaultTool == "" { + fmt.Println("auto (detects ngrok, then cloudflared, then Expose, then localhost.run)") + } else { + fmt.Println(cfg.Share.DefaultTool) + } + return nil + } + + tool := strings.ToLower(args[0]) + if tool == "auto" { + cfg.Share.DefaultTool = "" + if err := config.SaveGlobal(cfg); err != nil { + return err + } + feedback.Begin() + feedback.Done("share tool reset to " + feedback.Val("auto-detect")) + return nil + } + + bin, ok := shareToolBinaries[tool] + if !ok { + return fmt.Errorf("unknown tool %q: use ngrok, cloudflare, expose, serveo, localhost-run, or auto", tool) + } + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("%s requires %q which is not in PATH, install it first", tool, bin) + } + + cfg.Share.DefaultTool = tool + if err := config.SaveGlobal(cfg); err != nil { + return err + } + feedback.Begin() + feedback.Done("default share tool set to " + feedback.Val(tool)) + return nil +} diff --git a/internal/config/global.go b/internal/config/global.go index 01041f904..b7c0d5e65 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -102,6 +102,12 @@ type GlobalConfig struct { // would otherwise re-add the shims a `node:unmanage` removed. Managed *bool `yaml:"managed,omitempty" mapstructure:"managed"` } `yaml:"node" mapstructure:"node"` + Share struct { + // DefaultTool is the tunnel tool "lerd share" uses when no flag is + // given: ngrok | cloudflare | expose | serveo | localhost-run. + // Empty = auto-detect. Set via "lerd share:tool". + DefaultTool string `yaml:"default_tool,omitempty" mapstructure:"default_tool"` + } `yaml:"share,omitempty" mapstructure:"share"` Nginx struct { HTTPPort int `yaml:"http_port" mapstructure:"http_port"` HTTPSPort int `yaml:"https_port" mapstructure:"https_port"` From c93f13dbca2f51889d08702cc9bbc100637ba2ab Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Fri, 24 Jul 2026 10:47:14 +0300 Subject: [PATCH 4/4] feat(share): --domain selects Cloudflare Tunnel on its own Custom hostnames only exist for Cloudflare Tunnel, so requiring --cloudflare next to --domain was ceremony that told lerd something it could already work out. --domain now picks the tool itself and outranks a default set with share:tool, while combining it with a different tool flag stays an error rather than a silent override. A configured default whose binary has since gone missing used to fail with a bare "not found in PATH", which reads as lerd breaking rather than as a stale setting. The error now says the choice came from share:tool and how to undo it. Sharing a hostname whose tunnel was created on another machine died inside cloudflared with "tunnel credentials file not found", and by then lerd had already announced a public URL that was never going to work. The credentials are checked before anything is printed, and the error explains both ways out. A freshly routed hostname does not resolve straight away, and a resolver asked too early can hold on to the miss for half an hour while the tunnel sits there perfectly healthy. Routing a new record now warns about it. share:tool gained examples and an explicit list of what it accepts, and showing the current default now also says how to change it. --- docs/configuration.md | 5 ++ docs/reference/commands.md | 2 +- docs/usage/sites.md | 13 +-- internal/cli/share.go | 82 ++++++++++++++---- internal/cli/share_test.go | 144 ++++++++++++++++++++++++++++---- internal/cli/share_tool.go | 47 ++++++++--- internal/cli/share_tool_test.go | 123 +++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 47 deletions(-) create mode 100644 internal/cli/share_tool_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 6a26d9030..4bae75c67 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,11 @@ node: # / node:unmanage; honoured on lerd update so an opt-out # is not undone. Omitted on configs predating it, which # fall back to whatever shims are on disk. +share: + default_tool: cloudflare # optional. Tunnel tool lerd share uses when no tool + # flag is given: ngrok, cloudflare, expose, serveo or + # localhost-run. Written by lerd share:tool; omitted + # (the default) means auto-detect. nginx: http_port: 80 https_port: 443 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d5b3fe0c8..2faa780b2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,7 +69,7 @@ Setup steps include common tasks (composer install, npm install, lerd env) plus | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | | `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | -| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (Cloudflare Tunnel only) | +| `lerd share --domain ` | Expose the site on your own Cloudflare-managed hostname via a named tunnel (implies Cloudflare Tunnel) | | `lerd share:tool [tool]` | Show or set the default tunnel tool for `lerd share` (`auto` restores auto-detection) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd secure --renew [name]` | Reissue a secured site's TLS cert on demand, resetting its expiry | diff --git a/docs/usage/sites.md b/docs/usage/sites.md index b1045e27b..f22988a93 100644 --- a/docs/usage/sites.md +++ b/docs/usage/sites.md @@ -15,7 +15,7 @@ | `lerd domain list` | List all domains for the current site | | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | -| `lerd share [name]` | Expose the site publicly via ngrok or Expose (auto-detected) | +| `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | | `lerd secure [name]` | Issue a mkcert TLS cert and enable HTTPS, updates `APP_URL` in `.env` | | `lerd unsecure [name]` | Remove TLS and switch back to HTTP, updates `APP_URL` in `.env` | | `lerd pause [name]` | Pause a site: stop its workers and replace the vhost with a landing page | @@ -403,7 +403,7 @@ Lerd automatically creates a subdomain for each `git worktree` checkout. See [Gi | `lerd share --expose` | Force Expose | | `lerd share --localhost-run` | Force localhost.run (SSH, no signup) | | `lerd share --serveo` | Force serveo.net (SSH, no signup) | -| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (Cloudflare Tunnel only) | +| `lerd share --domain ` | Serve on your own Cloudflare-managed hostname (implies Cloudflare Tunnel) | | `lerd share:tool [tool]` | Show or set the default tunnel tool (`ngrok`, `cloudflare`, `expose`, `serveo`, `localhost-run`, or `auto`) | ### Default tunnel tool @@ -412,14 +412,17 @@ Auto-detection picks the first installed tool, which may not be the one you want ### Sharing on your own domain -Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare. It only applies to Cloudflare Tunnel, so pass `--cloudflare` or set it as your default tool first: +Quick tunnels hand out a fresh random `trycloudflare.com` URL on every run. When you need a stable URL (sending a client the same link twice, webhook or OAuth callback targets), pass `--domain` with a hostname whose DNS is managed by Cloudflare: ```bash -lerd share:tool cloudflare lerd share --domain dev.example.com ``` -On the first run cloudflared opens a browser window to authorize your Cloudflare account (a one-time login that writes `~/.cloudflared/cert.pem`). lerd then creates a named tunnel called `lerd-`, routes the hostname to it with a CNAME record, and starts the tunnel. Later runs reuse the same tunnel and hostname, so the URL never changes. If a DNS record for the hostname already exists, lerd leaves it alone and prints a note asking you to check that it points at the tunnel. +Custom hostnames are a Cloudflare Tunnel feature, so `--domain` selects that tool on its own. You never need `--cloudflare` alongside it, and it wins over a different default set with `lerd share:tool`. Combining it with another tool flag is rejected rather than silently ignored. + +On the first run cloudflared opens a browser window to authorize your Cloudflare account (a one-time login that writes `~/.cloudflared/cert.pem`). lerd then creates a named tunnel called `lerd-`, routes the hostname to it with a CNAME record, and starts the tunnel. Later runs reuse the same tunnel and hostname, so the URL never changes. Re-routing a hostname that already points at the same tunnel is a no-op; if the record exists but points somewhere else, lerd leaves it alone and prints a note asking you to check it. + +A freshly created DNS record takes a moment to become visible. If you open the URL in the first seconds and your resolver caches the miss, it can keep answering NXDOMAIN for up to 30 minutes even though the tunnel is healthy. A local reverse proxy rewrites the `Host` header to the site's domain so nginx routes to the correct vhost. Response `Location` headers and HTML/CSS/JS/JSON body references to the local domain are also rewritten to the public tunnel URL, so redirects and asset links work correctly in the browser. diff --git a/internal/cli/share.go b/internal/cli/share.go index 5b5162bad..52f49710f 100644 --- a/internal/cli/share.go +++ b/internal/cli/share.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "crypto/tls" + "encoding/json" "fmt" "io" "net" @@ -46,9 +47,9 @@ Supported tools: A default tool can be set with "lerd share:tool"; flags override it per run. -With --domain (Cloudflare Tunnel only), a named tunnel is created (or reused) -and the given hostname is routed to it, so the site is served on your own -domain instead of a random trycloudflare.com URL. The domain's DNS must be +--domain selects Cloudflare Tunnel on its own: a named tunnel is created (or +reused) and the given hostname is routed to it, so the site is served on your +own domain instead of a random trycloudflare.com URL. The domain's DNS must be managed by Cloudflare.`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { @@ -61,7 +62,7 @@ managed by Cloudflare.`, cmd.Flags().BoolVar(&useExpose, "expose", false, "Use Expose") cmd.Flags().BoolVar(&useServeo, "serveo", false, "Use serveo.net (SSH, no signup)") cmd.Flags().BoolVar(&useLocalhostRun, "localhost-run", false, "Use localhost.run (SSH, no signup)") - cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (Cloudflare Tunnel only)") + cmd.Flags().StringVar(&domain, "domain", "", "Serve on your own Cloudflare-managed hostname (implies Cloudflare Tunnel)") return cmd } @@ -203,9 +204,19 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu if count > 1 { return nil, fmt.Errorf("only one of --ngrok, --cloudflare, --expose, --serveo, --localhost-run may be specified") } + fromDefault := false - // No tool flag: fall back to the configured default before auto-detecting. - if count == 0 && defaultTool != "" { + // --domain only means anything for Cloudflare Tunnel, so it picks the tool + // itself and outranks the configured default. Another explicit tool flag is + // a real conflict, so say so instead of silently overriding it. + if domain != "" { + if count > 0 && !useCloudflare { + return nil, fmt.Errorf("--domain only works with Cloudflare Tunnel, drop the other tool flag") + } + useCloudflare = true + } else if count == 0 && defaultTool != "" { + // No tool flag: fall back to the configured default before auto-detecting. + fromDefault = true switch defaultTool { case "ngrok": useNgrok = true @@ -222,25 +233,21 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu } } - if domain != "" && !useCloudflare { - return nil, fmt.Errorf("--domain needs Cloudflare Tunnel: pass --cloudflare or set it as the default with \"lerd share:tool cloudflare\"") - } - if useNgrok { if _, err := exec.LookPath("ngrok"); err != nil { - return nil, fmt.Errorf("ngrok not found in PATH — install it from https://ngrok.com/download") + return nil, fmt.Errorf("ngrok not found in PATH — install it from https://ngrok.com/download%s", defaultToolHint(fromDefault)) } return &shareTool{mode: shareModeNgrok}, nil } if useCloudflare { if _, err := exec.LookPath("cloudflared"); err != nil { - return nil, fmt.Errorf("cloudflared not found in PATH — install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/") + return nil, fmt.Errorf("cloudflared not found in PATH — install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/%s", defaultToolHint(fromDefault)) } return &shareTool{mode: shareModeCloudflare, domain: domain}, nil } if useExpose { if _, err := exec.LookPath("expose"); err != nil { - return nil, fmt.Errorf("expose not found in PATH — install it from https://expose.dev") + return nil, fmt.Errorf("expose not found in PATH — install it from https://expose.dev%s", defaultToolHint(fromDefault)) } return &shareTool{mode: shareModeExpose}, nil } @@ -269,6 +276,15 @@ func pickShareTool(useNgrok, useCloudflare, useExpose, useServeo, useLocalhostRu return nil, fmt.Errorf("no tunnel tool found — install ngrok (https://ngrok.com/download), cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/), or Expose (https://expose.dev), or ensure ssh is in PATH") } +// defaultToolHint explains that a missing binary was picked by the configured +// default rather than by a flag, so a bare "lerd share" does not look broken. +func defaultToolHint(fromDefault bool) string { + if !fromDefault { + return "" + } + return "\nIt is your \"lerd share:tool\" default; run \"lerd share:tool auto\" to go back to auto-detection" +} + // cloudflaredCertPath returns the origin certificate cloudflared writes after // "tunnel login". TUNNEL_ORIGIN_CERT overrides it, mirroring cloudflared itself. func cloudflaredCertPath() string { @@ -302,12 +318,19 @@ func ensureCloudflareTunnel(siteName, domain string) (string, error) { name := "lerd-" + siteName out, err := exec.Command("cloudflared", "tunnel", "create", name).CombinedOutput() - if err != nil && !strings.Contains(string(out), "already exists") { + reused := err != nil && strings.Contains(string(out), "already exists") + if err != nil && !reused { return "", fmt.Errorf("cloudflared tunnel create %s: %w\n%s", name, err, out) } + if reused { + if err := checkTunnelCredentials(name); err != nil { + return "", err + } + } - // Route DNS is not idempotent: cloudflared refuses to touch an existing - // record, so tolerate that and let the user verify it points at this tunnel. + // Re-routing a hostname that already points here is a no-op, but cloudflared + // refuses to overwrite a record aimed elsewhere, so tolerate that and let the + // user verify where it points. out, err = exec.Command("cloudflared", "tunnel", "route", "dns", name, domain).CombinedOutput() if err != nil { if strings.Contains(string(out), "already exists") || strings.Contains(string(out), "already configured") { @@ -315,10 +338,37 @@ func ensureCloudflareTunnel(siteName, domain string) (string, error) { } else { return "", fmt.Errorf("cloudflared tunnel route dns %s %s: %w\n%s", name, domain, err, out) } + } else if strings.Contains(string(out), "Added CNAME") { + fmt.Printf("Routed %s to tunnel %q. The record is new, so give it a moment: opening it too early can leave your resolver caching the miss for up to 30 minutes.\n", domain, name) } return name, nil } +// checkTunnelCredentials verifies the credentials file for an existing tunnel is +// present locally. Without it "tunnel run" dies with an opaque cloudflared error, +// which is what happens when the tunnel was created on another machine. +func checkTunnelCredentials(name string) error { + out, err := exec.Command("cloudflared", "tunnel", "list", "--name", name, "--output", "json").Output() + if err != nil { + return nil // cannot tell, let "tunnel run" be the judge + } + var tunnels []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(out, &tunnels); err != nil || len(tunnels) == 0 { + return nil + } + cert := cloudflaredCertPath() + if cert == "" { + return nil + } + creds := filepath.Join(filepath.Dir(cert), tunnels[0].ID+".json") + if _, err := os.Stat(creds); err == nil { + return nil + } + return fmt.Errorf("tunnel %q exists on your Cloudflare account but its credentials file is missing at %s: copy it from the machine that created the tunnel, or run \"cloudflared tunnel delete %s\" and share again", name, creds, name) +} + // startHostProxy starts a local HTTP reverse proxy on a random loopback port. // It rewrites the Host header to domain before forwarding to nginx, // so nginx can route the request to the correct vhost. diff --git a/internal/cli/share_test.go b/internal/cli/share_test.go index 8c4b5aa0c..68700f244 100644 --- a/internal/cli/share_test.go +++ b/internal/cli/share_test.go @@ -255,13 +255,21 @@ func TestPickShareTool_domain_withCloudflareDefault(t *testing.T) { } } -func TestPickShareTool_domain_withoutCloudflare(t *testing.T) { +func TestPickShareTool_domain_impliesCloudflare(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok", "cloudflared")) - // No tool flag and no cloudflare default: --domain must not pick a tool itself. + // --domain is Cloudflare-only, so it selects the tool with no flag at all and + // outranks a configured default pointing somewhere else. for _, defaultTool := range []string{"", "ngrok"} { - _, err := pickShareTool(false, false, false, false, false, "dev.example.com", defaultTool) - if err == nil || !strings.Contains(err.Error(), "--domain needs Cloudflare Tunnel") { - t.Errorf("default %q: error = %v, want '--domain needs Cloudflare Tunnel'", defaultTool, err) + tool, err := pickShareTool(false, false, false, false, false, "dev.example.com", defaultTool) + if err != nil { + t.Errorf("default %q: unexpected error: %v", defaultTool, err) + continue + } + if tool.mode != shareModeCloudflare { + t.Errorf("default %q: mode = %v, want shareModeCloudflare", defaultTool, tool.mode) + } + if tool.domain != "dev.example.com" { + t.Errorf("default %q: domain = %q, want dev.example.com", defaultTool, tool.domain) } } } @@ -275,8 +283,8 @@ func TestPickShareTool_domain_withOtherTool(t *testing.T) { {false, false, false, true}, } { _, err := pickShareTool(p[0], false, p[1], p[2], p[3], "dev.example.com", "") - if err == nil || !strings.Contains(err.Error(), "--domain needs Cloudflare Tunnel") { - t.Errorf("pickShareTool%v: error = %v, want '--domain needs Cloudflare Tunnel'", p, err) + if err == nil || !strings.Contains(err.Error(), "--domain only works with Cloudflare Tunnel") { + t.Errorf("pickShareTool%v: error = %v, want '--domain only works with Cloudflare Tunnel'", p, err) } } } @@ -328,6 +336,36 @@ func TestPickShareTool_defaultTool_flagOverrides(t *testing.T) { } } +func TestPickShareTool_defaultTool_missingBinary_namesTheDefault(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + for _, c := range []struct{ defaultTool, wantBinary string }{ + {"ngrok", "ngrok"}, {"cloudflare", "cloudflared"}, {"expose", "expose"}, + } { + _, err := pickShareTool(false, false, false, false, false, "", c.defaultTool) + if err == nil { + t.Errorf("default %q: expected an error, got nil", c.defaultTool) + continue + } + if !strings.Contains(err.Error(), c.wantBinary+" not found") { + t.Errorf("default %q: error = %v, want %q not found", c.defaultTool, err, c.wantBinary) + } + if !strings.Contains(err.Error(), "lerd share:tool") { + t.Errorf("default %q: error should point at share:tool, got %v", c.defaultTool, err) + } + } +} + +func TestPickShareTool_explicitFlag_missingBinary_omitsTheHint(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := pickShareTool(true, false, false, false, false, "", "") + if err == nil { + t.Fatal("expected an error, got nil") + } + if strings.Contains(err.Error(), "share:tool") { + t.Errorf("an explicit --ngrok should not mention the default, got %v", err) + } +} + func TestPickShareTool_defaultTool_unknown(t *testing.T) { t.Setenv("PATH", fakeBin(t, "ngrok")) _, err := pickShareTool(false, false, false, false, false, "", "bogus") @@ -338,6 +376,10 @@ func TestPickShareTool_defaultTool_unknown(t *testing.T) { // ── ensureCloudflareTunnel ──────────────────────────────────────────────────── +// fakeTunnelID is the id the fake cloudflared reports from "tunnel list", and so +// the stem of the credentials file ensureCloudflareTunnel looks for. +const fakeTunnelID = "11111111-2222-3333-4444-555555555555" + // fakeCloudflared installs a fake cloudflared script and returns its log file. // The script logs every invocation; createExit/routeExit control the exit codes // and output of "tunnel create" / "tunnel route dns". "tunnel login" writes @@ -351,9 +393,10 @@ echo "$@" >> %q case "$1 $2" in "tunnel login") : > "$HOME/.cloudflared/cert.pem";; "tunnel create") echo %q; exit %d;; +"tunnel list") printf '[{"id":"%s","name":"%%s"}]\n' "$4";; "tunnel route") echo %q; exit %d;; esac -`, logFile, createOut, createExit, routeOut, routeExit) +`, logFile, createOut, createExit, fakeTunnelID, routeOut, routeExit) if err := os.WriteFile(filepath.Join(dir, "cloudflared"), []byte(script), 0755); err != nil { t.Fatal(err) } @@ -361,6 +404,34 @@ esac return logFile } +// certWithTunnelCredentials points TUNNEL_ORIGIN_CERT at a fresh cert with the +// credentials file for fakeTunnelID beside it, the state of a machine that +// created the tunnel itself. +func certWithTunnelCredentials(t *testing.T) { + t.Helper() + dir := newCertDir(t) + if err := os.WriteFile(filepath.Join(dir, fakeTunnelID+".json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } +} + +// certWithoutTunnelCredentials leaves the credentials file out, the state of a +// machine where the tunnel was created somewhere else. +func certWithoutTunnelCredentials(t *testing.T) { + t.Helper() + newCertDir(t) +} + +func newCertDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cert.pem"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("TUNNEL_ORIGIN_CERT", filepath.Join(dir, "cert.pem")) + return dir +} + func readCalls(t *testing.T, logFile string) string { t.Helper() b, _ := os.ReadFile(logFile) @@ -408,20 +479,65 @@ func TestEnsureCloudflareTunnel_skipsLoginWhenCertExists(t *testing.T) { } func TestEnsureCloudflareTunnel_toleratesExistingTunnelAndRoute(t *testing.T) { - cert := filepath.Join(t.TempDir(), "cert.pem") - if err := os.WriteFile(cert, []byte("x"), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("TUNNEL_ORIGIN_CERT", cert) + certWithTunnelCredentials(t) fakeCloudflared(t, "tunnel with name already exists", 1, "record with that host already exists", 1) - name, err := ensureCloudflareTunnel("mysite", "dev.example.com") + var name string + var err error + out := captureStdout(t, func() { name, err = ensureCloudflareTunnel("mysite", "dev.example.com") }) if err != nil { t.Fatalf("unexpected error: %v", err) } if name != "lerd-mysite" { t.Errorf("name = %q, want lerd-mysite", name) } + if !strings.Contains(out, "already exists") { + t.Errorf("expected a note about the existing record, got %q", out) + } +} + +func TestEnsureCloudflareTunnel_existingTunnelMissingCredentials(t *testing.T) { + certWithoutTunnelCredentials(t) + fakeCloudflared(t, "tunnel with name already exists", 1, "routed", 0) + + _, err := ensureCloudflareTunnel("mysite", "dev.example.com") + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), "credentials file is missing") { + t.Errorf("error = %v, want it to name the missing credentials file", err) + } + if !strings.Contains(err.Error(), fakeTunnelID+".json") { + t.Errorf("error = %v, want it to name the expected credentials path", err) + } +} + +func TestEnsureCloudflareTunnel_freshRouteWarnsAboutPropagation(t *testing.T) { + certWithTunnelCredentials(t) + fakeCloudflared(t, "created", 0, "Added CNAME dev.example.com which will route to this tunnel", 0) + + out := captureStdout(t, func() { + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, "30 minutes") { + t.Errorf("a freshly added record should warn about resolver caching, got %q", out) + } +} + +func TestEnsureCloudflareTunnel_reusedRouteStaysQuiet(t *testing.T) { + certWithTunnelCredentials(t) + fakeCloudflared(t, "created", 0, "", 0) + + out := captureStdout(t, func() { + if _, err := ensureCloudflareTunnel("mysite", "dev.example.com"); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + if strings.Contains(out, "30 minutes") { + t.Errorf("an unchanged route should not warn about propagation, got %q", out) + } } func TestEnsureCloudflareTunnel_createFailurePropagates(t *testing.T) { diff --git a/internal/cli/share_tool.go b/internal/cli/share_tool.go index 7c6e249c2..244849949 100644 --- a/internal/cli/share_tool.go +++ b/internal/cli/share_tool.go @@ -10,14 +10,32 @@ import ( "github.com/spf13/cobra" ) -// shareToolBinaries maps a default-tool name to the binary it needs in PATH. -// SSH-based tools need no dedicated binary beyond ssh itself. -var shareToolBinaries = map[string]string{ - "ngrok": "ngrok", - "cloudflare": "cloudflared", - "expose": "expose", - "serveo": "ssh", - "localhost-run": "ssh", +// shareTools lists the settable default tunnel tools in the order they are +// offered, with the binary each one needs in PATH. SSH-based tools need no +// dedicated binary beyond ssh itself. +var shareTools = []struct{ name, binary string }{ + {"ngrok", "ngrok"}, + {"cloudflare", "cloudflared"}, + {"expose", "expose"}, + {"serveo", "ssh"}, + {"localhost-run", "ssh"}, +} + +func shareToolNames() []string { + names := make([]string, 0, len(shareTools)) + for _, t := range shareTools { + names = append(names, t.name) + } + return names +} + +func shareToolBinary(name string) (string, bool) { + for _, t := range shareTools { + if t.name == name { + return t.binary, true + } + } + return "", false } // NewShareToolCmd returns the share:tool command. @@ -29,7 +47,13 @@ func NewShareToolCmd() *cobra.Command { With an argument, sets the tool "lerd share" uses when no tool flag is passed. "auto" clears the default and restores auto-detection -(ngrok, then cloudflared, then Expose, then localhost.run).`, +(ngrok, then cloudflared, then Expose, then localhost.run). + +The tool must already be installed. A tool flag on "lerd share" still wins for +that run, and "lerd share --domain" always uses Cloudflare Tunnel.`, + Example: ` lerd share:tool + lerd share:tool cloudflare + lerd share:tool auto`, Args: cobra.MaximumNArgs(1), RunE: runShareTool, } @@ -47,6 +71,7 @@ func runShareTool(_ *cobra.Command, args []string) error { } else { fmt.Println(cfg.Share.DefaultTool) } + fmt.Printf("\nChange it with: lerd share:tool %s|auto\n", strings.Join(shareToolNames(), "|")) return nil } @@ -61,9 +86,9 @@ func runShareTool(_ *cobra.Command, args []string) error { return nil } - bin, ok := shareToolBinaries[tool] + bin, ok := shareToolBinary(tool) if !ok { - return fmt.Errorf("unknown tool %q: use ngrok, cloudflare, expose, serveo, localhost-run, or auto", tool) + return fmt.Errorf("unknown tool %q: use %s, or auto", tool, strings.Join(shareToolNames(), ", ")) } if _, err := exec.LookPath(bin); err != nil { return fmt.Errorf("%s requires %q which is not in PATH, install it first", tool, bin) diff --git a/internal/cli/share_tool_test.go b/internal/cli/share_tool_test.go new file mode 100644 index 000000000..b9b4cf968 --- /dev/null +++ b/internal/cli/share_tool_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/geodro/lerd/internal/config" +) + +func TestShareToolNames_matchBinaries(t *testing.T) { + names := shareToolNames() + if len(names) != len(shareTools) { + t.Fatalf("shareToolNames() returned %d names for %d tools", len(names), len(shareTools)) + } + for _, name := range names { + bin, ok := shareToolBinary(name) + if !ok { + t.Errorf("%q is offered but has no binary mapping", name) + } + if bin == "" { + t.Errorf("%q maps to an empty binary", name) + } + } + if _, ok := shareToolBinary("auto"); ok { + t.Error(`"auto" must not resolve to a binary, it clears the default`) + } + if _, ok := shareToolBinary("bogus"); ok { + t.Error("an unknown tool must not resolve to a binary") + } +} + +// The order is what the command line and the "change it with" hint offer, and it +// mirrors the auto-detection order in pickShareTool. +func TestShareToolNames_order(t *testing.T) { + want := "ngrok|cloudflare|expose|serveo|localhost-run" + if got := strings.Join(shareToolNames(), "|"); got != want { + t.Errorf("shareToolNames() = %q, want %q", got, want) + } +} + +func TestNewShareToolCmd_helpMentionsEveryTool(t *testing.T) { + cmd := NewShareToolCmd() + help := cmd.Use + "\n" + cmd.Long + "\n" + cmd.Example + for _, name := range shareToolNames() { + if !strings.Contains(help, name) { + t.Errorf("help does not mention %q:\n%s", name, help) + } + } + for _, want := range []string{"auto", "lerd share:tool cloudflare"} { + if !strings.Contains(help, want) { + t.Errorf("help does not mention %q:\n%s", want, help) + } + } +} + +func TestRunShareTool_rejectsUnknownTool(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + err := runShareTool(nil, []string{"bogus"}) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), `unknown tool "bogus"`) { + t.Errorf("error = %v, want it to name the rejected tool", err) + } + for _, name := range shareToolNames() { + if !strings.Contains(err.Error(), name) { + t.Errorf("error should list %q as a valid choice, got %v", name, err) + } + } +} + +func TestRunShareTool_rejectsToolWithoutItsBinary(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + err := runShareTool(nil, []string{"ngrok"}) + if err == nil { + t.Fatal("expected an error, got nil") + } + if !strings.Contains(err.Error(), "not in PATH") { + t.Errorf("error = %v, want it to say the binary is missing", err) + } +} + +func TestRunShareTool_setShowReset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("PATH", fakeBin(t, "ngrok")) + + if err := runShareTool(nil, []string{"ngrok"}); err != nil { + t.Fatalf("setting ngrok: %v", err) + } + cfg, err := config.LoadGlobal() + if err != nil { + t.Fatalf("loading config: %v", err) + } + if cfg.Share.DefaultTool != "ngrok" { + t.Errorf("DefaultTool = %q, want ngrok", cfg.Share.DefaultTool) + } + + // Showing the default also has to say how to change it, otherwise the command + // reports a value with no way to act on it. + out := captureStdout(t, func() { + if err := runShareTool(nil, nil); err != nil { + t.Errorf("showing: %v", err) + } + }) + if !strings.Contains(out, "ngrok") { + t.Errorf("show output %q does not report the current tool", out) + } + if !strings.Contains(out, "lerd share:tool") { + t.Errorf("show output %q does not say how to change it", out) + } + + if err := runShareTool(nil, []string{"auto"}); err != nil { + t.Fatalf("resetting: %v", err) + } + cfg, err = config.LoadGlobal() + if err != nil { + t.Fatalf("reloading config: %v", err) + } + if cfg.Share.DefaultTool != "" { + t.Errorf("DefaultTool = %q after auto, want it cleared", cfg.Share.DefaultTool) + } +}