diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf561a9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to Expose will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] +### Planned for v0.2.0 + +### Planned for v0.2.0 +- ngrok provider support +- Custom subdomains +- HTTPS support + +## [v0.2.0] - 2025-11-29 + +### Added +- **Cloudflare Tunnel** support (`expose tunnel -P cloudflare`) [#21] +- `--provider/-P` flag (localtunnel, cloudflare) +- test coverage for provider + service layers + +### Changed +- Bump version to v0.2.0 +- Update README with providers table + examples +## [v0.2.0] - 2025-11-29 + +### Added +- **Cloudflare Tunnel** support (`expose tunnel -P cloudflare`) [#12] +- `--provider/-P` flag (localtunnel, cloudflare) +- Full test coverage for provider + service layers + +### Changed +- Bump version to v0.2.0 +- Update README with providers table + examples + +--- + +## [0.1.2] - 2025-11-10 + +### Added +- Config management commands (`config list`, `config get`) +- Service layer with thread-safe tunnel management +- `--version` flag with commit and build date metadata + +### Changed +- Improved error messages for tunnel lifecycle +- Better context cancellation handling + +### Fixed +- Race conditions in Service.Start() +- Graceful shutdown on Ctrl+C + +--- + +## [0.1.1] - 2025-11-09 + +### Added +- LocalTunnel provider integration +- 6 unit tests for Service layer (75%+ coverage) +- Provider interface for extensibility + +### Changed +- Refactored tunnel command to use Service layer +- Separated CLI logic from business logic + +--- + +## [0.1.0] - 2025-11-07 + +### Added +- Initial release +- `expose init` - Create `.expose.yml` config +- `expose tunnel` - Start local reverse proxy +- Cobra CLI framework +- GitHub Actions CI/CD (test.yml) +- Basic test coverage (tunnel package) + +--- + +[v0.1.2]: https://github.com/kernelshard/expose/compare/v0.2.0...v0.1.2 +[0.1.1]: https://github.com/kernelshard/expose/releases/tag/v0.1.1 +[0.1.0]: https://github.com/kernelshard/expose/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1eefcc4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,167 @@ +# Contributing to Expose + +Thanks for your interest in contributing! This guide will help you get started. + +--- + +## Development Setup + +### Prerequisites +- Go 1.23+ +- Git + +### Clone and Build + +``` +git clone https://github.com/kernelshard/expose.git +cd expose +go mod download +go build -o expose ./cmd/expose +./expose --version +``` + +--- + +## Workflow + +### Branch Strategy + +1. **Always branch from `develop`** (not `main`) + ``` + git checkout develop + git pull origin develop + git checkout -b feature/your-feature-name + ``` + +2. **Branch naming:** + - Features: `feat/add-ngrok-provider` + - Fixes: `fix/config-load-error` + - Docs: `docs/update-readme` + - Tests: `test/add-provider-tests` + +3. **Create PR to `develop`** (not `main`) + - Base branch: `develop` + - Title: Use commit prefix (`feat:`, `fix:`, `docs:`, `test:`) + - Description: Reference issue number, explain changes + +4. **After merge:** We merge `develop` → `main` only for releases + +--- + +## Code Standards + +### Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add ngrok provider support +fix: resolve config file not found error +docs: update installation instructions +test: add tunnel service tests +``` + +### Testing Requirements + +- ✅ Write tests for new features +- ✅ Minimum **75% coverage** +- ✅ Run with race detector: `go test -race ./...` +- ✅ Use table-driven tests for multiple cases + +**Example:** + +``` +func TestConfigLoad(t *testing.T) { + tests := []struct { + name string + config string + wantErr bool + }{ + {"valid config", "port: 3000", false}, + {"invalid yaml", "port:", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test body + }) + } +} +``` + +### Code Style + +- Run `go fmt` before committing +- Follow [Effective Go](https://golang.org/doc/effective_go) +- Keep CLI layer thin (delegate to service layer) +- Export struct fields only when needed (YAML marshaling) +- Return errors early + +**File organization:** + +``` +internal/ +├── cli/ # Cobra commands (newXXXCmd() factory pattern) +├── config/ # Config CRUD operations +├── provider/ # Provider implementations +├── tunnel/ # Service layer (business logic) +└── version/ # Version metadata +``` + +--- + +## Running Tests + +``` +# All tests with race detector +go test ./... -v -race -cover + +# Specific package coverage +go test ./internal/config -cover +go test ./internal/tunnel -cover + +# Coverage report +go test ./... -coverprofile=coverage.out +go tool cover -html=coverage.out +``` + +--- + +## Testing Locally + +``` +# Build +go build -o expose ./cmd/expose + +# Run tunnel +./expose tunnel + +# Test with HTTP server +python3 -m http.server 3000 # Terminal 1 +./expose tunnel # Terminal 2 +curl # Terminal 3 +``` + +--- + +## Pull Request Checklist + +Before submitting: + +- [ ] Tests pass: `go test ./... -race -cover` +- [ ] Code formatted: `go fmt ./...` +- [ ] Coverage ≥ 75% +- [ ] Commit messages follow convention +- [ ] PR description includes issue reference +- [ ] Branch is up to date with `develop` + +--- + +## Need Help? + +- **Issues:** [github.com/kernelshard/expose/issues](https://github.com/kernelshard/expose/issues) +- **Discussions:** Comment on relevant issue + +--- + +**Made with ❤️ by contributors like you.** diff --git a/README.md b/README.md index 794d109..16e654d 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,226 @@ -# expose +# 🚀 Expose [![Tests](https://github.com/kernelshard/expose/actions/workflows/test.yml/badge.svg)](https://github.com/kernelshard/expose/actions/workflows/test.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/kernelshard/expose)](https://goreportcard.com/report/github.com/kernelshard/expose) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/kernelshard/expose/blob/main/LICENSE) -> Expose localhost to the internet. Minimal. Fast. Open Source. - -**expose** is a lightweight Golang CLI that makes sharing your local development server effortless. +> Minimal CLI tool to expose your local dev server to the internet +**Expose** lets you share your `localhost` with the world — perfect for testing webhooks, demoing work, or debugging on mobile devices. Supports LocalTunnel and Cloudflare Tunnel. ## ✨ Features -- 🚀 **One Command**: Expose your local server instantly -- ⚙️ **Zero Config**: Works out of the box with sensible defaults -- 🔒 **Privacy First**: Self-hostable, no vendor lock-in -- 🎯 **Minimal**: Single binary, no runtime dependencies - -## 📦 Installation + 🌐 **Multiple providers** — LocalTunnel + Cloudflare Tunnel +- ⚡ **Zero signup** — No accounts, no registration required +- � **Config management** — Save port settings per project +- 📦 **Single binary** — No Node.js, Python, or runtime dependencies +- 🧪 **Production-tested** — 75%+ test coverage, CI/CD pipeline -``` -# Clone the repository -git clone https://github.com/kernelshard/expose.git -cd expose +--- -# Build -go build -o expose cmd/expose/main.go +## 🚀 Quick Start -# Optional: Install globally +```bash +# Install go install github.com/kernelshard/expose/cmd/expose@latest -``` -## 🚀 Quick Start - -``` -# 1. Initialize configuration +# Initialize config expose init -# 2. Expose your local server +# Start tunnel expose tunnel - -# 3. Access via http://localhost:8080 ``` -## 📖 Usage ++ # Or use Cloudflare Tunnel +```bash +expose tunnel -P cloudflare -p 3000 +``` +--- -### Version +## 📦 Installation -Check the version of expose: +### Using Go Install +```bash +go install github.com/kernelshard/expose/cmd/expose@latest ``` -expose --version -# expose version v0.1.1 (commit: 4784595, built: 2025-11-07) + +### From Source + +```bash +git clone https://github.com/kernelshard/expose.git +cd expose +go build -o expose ./cmd/expose +./expose --version ``` -### Initialize Project +--- -Create a `.expose.yml` configuration file: +## 📖 Usage -``` -expose init -``` +### Initialize Configuration -This generates: +```bash +$ expose init +✓ Config created: .expose.yml ``` -project: my-app -default_port: 3000 + +Creates `.expose.yml` in current directory: + +```yaml +project: expose +port: 3000 ``` -### Expose Local Server +### Start Tunnel -Start exposing your local development server: +```bash +# Use config port +$ expose tunnel +✓ Tunnel (LocalTunnel) started for localhost:3000 +✓ Public URL: https://quick-mammals-sing.loca.lt +✓ Forwarding to http://localhost:3000 +✓ Provider: LocalTunnel +✓ Press Ctrl+C to stop +# Override port +$ expose tunnel --port 8080 ``` -# Use default port from config -expose tunnel -# Specify custom port -expose tunnel --port 8080 -``` +### Manage Configuration -## ⚙️ Configuration +```bash +# List all config values +$ expose config list +project: expose +port: 3000 -Edit `.expose.yml` to customize settings: +# Get specific value +$ expose config get port +3000 -``` -project: "my-awesome-app" -default_port: 3000 +$ expose config get project +expose ``` -## 🏗️ Architecture +--- +## ✅ Tested Locally + +```bash +$ expose --version +expose version v0.1.2 (commit: d30c483, built: 2025-11-10) + +$ expose init +✓ Config created: .expose.yml (project: expose, port: 3000) + +$ python3 -m http.server 3000 & +Serving HTTP on 0.0.0.0 port 3000... + +$ expose tunnel +🚀 Tunnel[LocalTunnel] started for localhost:3000 +✓ Public URL: https://ripe-garlics-add.loca.lt +✓ Forwarding to: http://localhost:3000 +✓ Provider: LocalTunnel +Press Ctrl+C to stop + +$ curl https://ripe-garlics-add.loca.lt +... # Works! ``` + +**Tested on:** Go 1.23, macOS 14, Ubuntu 22.04 + +--- + +## 🏗 Architecture + +```text expose/ -├── cmd/expose/ # CLI entry point -└── internal/ - ├── cli/ # Command implementations - ├── config/ # Configuration management - ├── env/ # Environment handling - ├── git/ # Git integration - ├── preview/ # Preview functionality - ├── state/ # State management - └── tunnel/ # Tunnel management +├── cmd/expose/ # CLI entry point +├── internal/ +│ ├── cli/ # Cobra commands (thin layer) +│ ├── config/ # YAML config management +│ ├── provider/ # Tunnel provider interface +│ ├── tunnel/ # Service layer (business logic) +│ └── version/ # Version metadata +└── .expose.yml # User config (add to .gitignore per project) ``` -**Design Principles:** -- Idiomatic Go code -- Clean architecture -- Minimal dependencies -- Easy to contribute +**Design principles:** +- **Interface-driven** — `Provider` interface supports multiple tunnel backends +- **Clean separation** — CLI → Service → Provider (no circular deps) +- **Testable** — Real file tests, injectable service layer -## 🛠️ Development +--- -``` -# Install dependencies +## ⚠️ Known Limitations +- **One tunnel per process** — Each `expose tunnel` command runs independently (can run multiple on different ports) +- **No persistence** — Public URLs change on restart +- **CLI-only** — No web UI or dashboard yet + +See [GitHub Issues](https://github.com/kernelshard/expose/issues) for roadmap. + +--- + +## 🧪 Development + +### Prerequisites +- Go 1.23+ +- Git + +### Setup + +```bash +git clone https://github.com/kernelshard/expose.git +cd expose go mod download +``` -# Run locally -go run cmd/expose/main.go init +### Run Tests -# Build -go build -o expose cmd/expose/main.go +```bash +# Run all tests with race detector +go test ./... -v -race -cover -# Format code -go fmt ./... +# Check coverage for specific packages +go test ./internal/config -cover +go test ./internal/tunnel -cover +go test ./internal/provider -cover ``` -## 🗺️ Roadmap +### Build -- [x] Basic tunnel functionality -- [ ] Localtunnel/ngrok-style public URLs -- [ ] Branch-aware environment switching -- [ ] PR preview environments -- [ ] Custom tunnel server support +```bash +go build -o expose ./cmd/expose +./expose --version +``` -## 🤝 Contributing +### Run Locally -Contributions welcome! This project follows: -- Standard Go conventions -- Commit message format: `type: description` -- Clean, tested, documented code +```bash +# Without installing +go run cmd/expose/main.go tunnel -### Contributors +# Test with live server +python3 -m http.server 3000 # Terminal 1 +./expose tunnel # Terminal 2 +``` -- **Samiul Sk** - Project creator and maintainer +--- -*Want to contribute? See our [contributing guidelines](CONTRIBUTING.md) (coming soon)* +## 🤝 Contributing -## 📝 License +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for: +- Development workflow +- Branch strategy +- Testing requirements +- Code style guidelines -MIT License - see [LICENSE](LICENSE) for details. +--- -## 🙏 Acknowledgments +## 📝 License -Built with: -- [Cobra](https://github.com/spf13/cobra) - CLI framework -- Go standard library - Minimal and powerful +MIT License - see [LICENSE](LICENSE) for details. --- -**Status:** Early development - contributions welcome! +**Made with ❤️ by [@kernelshard](https://github.com/kernelshard)** diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 381cb4e..b2740e2 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -21,10 +21,15 @@ func newTunnelCmd() *cobra.Command { //RunE: runTunnelCmd, cmd := &cobra.Command{ Use: "tunnel", - Short: "Start a tunnel to expose local server", + Short: "Expose local server via tunnel", RunE: runTunnelCmd, } + // Define flags + // provider flag to specify provider e.g. expose tunnel --provider cloudflare + cmd.Flags().StringP("provider", "P", "localtunnel", "Tunnel provider: localtunnel, cloudflare, etc. defaults to localtunnel") + + // port flag to specify local port e.g. expose tunnel --port 8080 cmd.Flags().IntP("port", "p", 0, "Local port to expose (overrides config)") return cmd } @@ -53,17 +58,27 @@ func runTunnelCmd(cmd *cobra.Command, _ []string) error { return fmt.Errorf("invalid port %d (must be 1-65535)", port) } - return runTunnel(port) + // use provider flag shorthand -P to select provider + providerName, err := cmd.Flags().GetString("provider") + if err != nil { + return fmt.Errorf("invalid port %d (must be 1-65535)", port) + } + + return runTunnel(port, providerName) } // runTunnel sets up a reverse proxy to expose the local server // on the specified port. -func runTunnel(port int) error { - // - Create LocalTunnel provider - lt := provider.NewLocalTunnel(nil) +func runTunnel(port int, providerName string) error { + var svc *tunnel.Service + + switch providerName { + case "cloudflare": + svc = tunnel.NewService(provider.NewCloudFlare()) + default: + svc = tunnel.NewService(provider.NewLocalTunnel(nil)) - // - Wrap in service - svc := tunnel.NewService(lt) + } // Setup ctx & signal handling ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/provider/cloudflare.go b/internal/provider/cloudflare.go new file mode 100644 index 0000000..2279303 --- /dev/null +++ b/internal/provider/cloudflare.go @@ -0,0 +1,143 @@ +package provider + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "regexp" + "sync" + "time" +) + +// Cloudflare implements the Provider interface for Cloudflare Tunnel +type Cloudflare struct { + cmd *exec.Cmd + mu sync.RWMutex + publicURL string + + // RequestTunnel is exported for test mocking + RequestTunnel func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) +} + +// NewCloudFlare creates a new instance of Cloudflare provider +func NewCloudFlare() *Cloudflare { + return &Cloudflare{ + RequestTunnel: requestTunnel, // Use real implementation by default + } +} + +// Connect establishes a Cloudflare Tunnel to the specified local port +func (c *Cloudflare) Connect(ctx context.Context, localPort int) (string, error) { + timeout := 30 * time.Second + url, cmd, err := c.RequestTunnel(ctx, localPort, timeout) + if err != nil { + return "", err + } + + c.mu.Lock() + c.cmd = cmd + c.publicURL = url + c.mu.Unlock() + + return url, nil +} + +// Close terminates the Cloudflare Tunnel +func (c *Cloudflare) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + // if cmd is running, kill the process + if c.cmd != nil && c.cmd.Process != nil { + err := c.cmd.Process.Kill() + // clear fields safely under write lock + c.cmd = nil + c.publicURL = "" + return err + } + return nil +} + +// PublicURL returns the public URL of the Cloudflare Tunnel +func (c *Cloudflare) PublicURL() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.publicURL +} + +// IsConnected checks if the Cloudflare Tunnel is active +func (c *Cloudflare) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.cmd != nil && c.cmd.ProcessState == nil +} + +// Name returns the name of the provider +func (c *Cloudflare) Name() string { + return "Cloudflare" +} + +// requestTunnel starts the cloudflared process and retrieves the public URL +func requestTunnel(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + urlRegex := regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) + + cmd := exec.CommandContext(ctx, "cloudflared", "tunnel", "--url", fmt.Sprintf("http://localhost:%d", port)) + + stderr, err := cmd.StderrPipe() + if err != nil { + return "", nil, fmt.Errorf("get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("start cloudflared: %w", err) + } + + urlCh := make(chan string, 1) + errCh := make(chan error, 1) + + // Read stderr for URL + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) // logs + + if url := urlRegex.FindString(line); url != "" { + urlCh <- url + return + } + } + + // Handle scanner error or no URL found + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("read stderr: %w", err) + } else { + errCh <- fmt.Errorf("cloudflared exited without providing URL") + } + }() + + // Wait for result with timeout + select { + case url := <-urlCh: + // Success - return cmd so caller can manage it + return url, cmd, nil + + case err := <-errCh: + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, err + + case <-time.After(timeout): + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, fmt.Errorf("timeout waiting for tunnel URL") + + case <-ctx.Done(): + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, ctx.Err() + } +} diff --git a/internal/provider/cloudflare_test.go b/internal/provider/cloudflare_test.go new file mode 100644 index 0000000..8fdd548 --- /dev/null +++ b/internal/provider/cloudflare_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "context" + "errors" + "os/exec" + "testing" + "time" +) + +// TestCloudflare_Connect tests the Connect method of Cloudflare provider +func TestCloudflare_Connect(t *testing.T) { + cf := NewCloudFlare() + + // Mock RequestTunnel + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + return "https://test-tunnel.trycloudflare.com", nil, nil + } + + url, err := cf.Connect(context.Background(), 3000) + if err != nil { + t.Fatalf("Connect() failed: %v", err) + } + + if url != "https://test-tunnel.trycloudflare.com" { + t.Errorf("got %s, want test URL", url) + } + + if cf.PublicURL() != url { + t.Errorf("PublicURL() = %s, want %s", cf.PublicURL(), url) + } +} + +// TestCloudflare_ConnectError tests the Connect method when RequestTunnel returns an error +func TestCloudflare_ConnectError(t *testing.T) { + cf := NewCloudFlare() + + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + return "", nil, errors.New("tunnel creation failed") + } + + _, err := cf.Connect(context.Background(), 3000) + if err == nil { + t.Fatal("Expected error, got nil") + } + + if cf.PublicURL() != "" { + t.Errorf("PublicURL() should be empty, got %s", cf.PublicURL()) + } +} + +// TestCloudflare_Name tests the Name method of Cloudflare provider +func TestCloudflare_Name(t *testing.T) { + cf := NewCloudFlare() + if got := cf.Name(); got != "Cloudflare" { + t.Errorf("Name() = %s, want Cloudflare", got) + } +} + +// TestCloudflare_CloseBeforeConnect tests that Close can be called before Connect without error +func TestCloudflare_CloseBeforeConnect(t *testing.T) { + cf := NewCloudFlare() + if err := cf.Close(); err != nil { + t.Errorf("Close() before Connect error: %v", err) + } +} + +// TestCloudflare_ConnectTimeout tests the Connect method with a context timeout +func TestCloudflare_ConnectTimeout(t *testing.T) { + cf := NewCloudFlare() + // Mock RequestTunnel to simulate delay + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + <-ctx.Done() + return "", nil, ctx.Err() + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := cf.Connect(ctx, 3000) + if err == nil { + t.Fatal("Expected timeout error, got nil") + } +} diff --git a/internal/tunnel/service_test.go b/internal/tunnel/service_test.go index 3aa8cc3..6558b81 100644 --- a/internal/tunnel/service_test.go +++ b/internal/tunnel/service_test.go @@ -159,3 +159,12 @@ func TestService_StartTwice(t *testing.T) { t.Error("already started error shall be returned") } } + +func TestService_Providername(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + if got := svc.ProviderName(); got != "MockProvider" { + t.Errorf("ProviderName() = %s, want MockProvider", got) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 8763d1b..2e05502 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,9 +1,9 @@ package version var ( - Version = "v0.1.2" - GitCommit = "d30c483" - BuildDate = "2025-11-10" + Version = "v0.2.0" + GitCommit = "8fdff9e" + BuildDate = "2025-11-29" ) // GetVersion returns just the version string