Skip to content

Commit 6af3225

Browse files
v0.1.0 — zero-config HTTPS for local dev (#1)
* chore: init go module and licence * chore: add version constant and main stub * feat: cert cache expiry check * feat: cert cache dir resolver * feat: mkcert shell-out with cache reuse * feat: reverse proxy handler with event stream * feat: log-mode UI with banner and colorized requests * feat: wire main entrypoint with log-mode default * test: integration test for proxy and 502 fallback * ci: add build+test workflow * feat: bubbletea TUI dashboard behind --tui * fix: use Catppuccin Mocha palette in TUI (correct hex values) * docs: real README with usage examples * ci: goreleaser release workflow * feat: redesign TUI with big wordmark and rule-based layout * feat: swap wordmark to mono12 block chars, expand tagline
1 parent c0b13f3 commit 6af3225

16 files changed

Lines changed: 1085 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main, 'feat/**']
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ${{ matrix.os }}
11+
strategy:
12+
matrix:
13+
os: [ubuntu-latest, macos-latest]
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-go@v5
17+
with:
18+
go-version: '1.22'
19+
- run: go build ./...
20+
- run: go test -race -v ./...

.github/workflows/release.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
goreleaser:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
- uses: actions/setup-go@v5
19+
with:
20+
go-version: '1.22'
21+
- uses: goreleaser/goreleaser-action@v6
22+
with:
23+
version: latest
24+
args: release --clean
25+
env:
26+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.goreleaser.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
version: 2
2+
3+
before:
4+
hooks:
5+
- go mod tidy
6+
7+
builds:
8+
- id: httpsdev
9+
main: .
10+
binary: httpsdev
11+
env:
12+
- CGO_ENABLED=0
13+
goos: [linux, darwin, windows]
14+
goarch: [amd64, arm64]
15+
ignore:
16+
- goos: windows
17+
goarch: arm64
18+
19+
archives:
20+
- name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
21+
format_overrides:
22+
- goos: windows
23+
format: zip
24+
25+
checksum:
26+
name_template: "checksums.txt"
27+
28+
changelog:
29+
sort: asc
30+
filters:
31+
exclude:
32+
- "^docs:"
33+
- "^test:"
34+
- "^ci:"
35+
- "^chore:"

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Arpit Rajput
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,95 @@
11
# httpsdev
2-
Zero-config HTTPS for any local dev server. Wraps npm/yarn/pnpm/python/rails/etc with a browser-trusted TLS cert via mkcert.
2+
3+
**Zero-config HTTPS for any local dev server.**
4+
5+
```
6+
$ mkcert -install # one time, system-wide
7+
$ httpsdev 5173 # https://localhost:3443 → http://localhost:5173
8+
```
9+
10+
Real browser-trusted cert. Works with Vite, Next, Rails, Django, Flask — any HTTP dev server. WebSocket + SSE pass through untouched, so HMR keeps working.
11+
12+
## Why
13+
14+
Every framework has its own HTTPS story (`--https`, `HTTPS=true`, experimental configs) and most default to self-signed certs that trip browser warnings, break service workers, and blow up OAuth callbacks. `httpsdev` sits in front of your existing dev server and gives it real HTTPS in one command.
15+
16+
## Install
17+
18+
Requires [`mkcert`](https://github.com/FiloSottile/mkcert) for the local CA:
19+
20+
```bash
21+
brew install mkcert
22+
mkcert -install
23+
```
24+
25+
Then:
26+
27+
```bash
28+
# Homebrew (coming in v0.2)
29+
30+
# Go install
31+
go install github.com/ArpitRajputGithub/httpsdev@latest
32+
33+
# Or grab a binary from Releases:
34+
# https://github.com/ArpitRajputGithub/httpsdev/releases
35+
```
36+
37+
## Usage
38+
39+
```
40+
httpsdev <target-port> [flags]
41+
42+
--listen <port> HTTPS listen port (default 3443)
43+
--host <name> Additional SAN for cert (default: none)
44+
--lan Bind on 0.0.0.0 for LAN (default: 127.0.0.1)
45+
--tui Full-screen dashboard mode (default: false)
46+
--version Print version
47+
```
48+
49+
### Log mode (default)
50+
51+
```
52+
$ httpsdev 5173
53+
54+
▲ httpsdev v0.1.0
55+
56+
➜ Local: https://localhost:3443
57+
➜ Upstream: http://localhost:5173
58+
➜ Cert: mkcert · valid 30 days
59+
60+
press Ctrl+C to quit
61+
62+
GET / 200 12ms
63+
GET /@vite/client 200 3ms
64+
POST /api/login 401 45ms
65+
```
66+
67+
### Dashboard mode
68+
69+
```bash
70+
httpsdev --tui 5173
71+
```
72+
73+
Full-screen Catppuccin Mocha TUI with live request feed, latency stats, and cert info tab.
74+
75+
*(GIF placeholder — record with asciinema after v0.1 tag)*
76+
77+
### Test on your phone
78+
79+
```bash
80+
httpsdev --lan 5173
81+
```
82+
83+
Binds on `0.0.0.0`. Your phone (on the same wifi) can hit `https://<mac-ip>:3443` — install the mkcert root CA on the phone via `mkcert -CAROOT` to make the padlock green.
84+
85+
## What's out of scope for v0.1
86+
87+
- Wrapping the dev-server process (`httpsdev npm run dev`) — you run your dev server yourself.
88+
- Multi-service reverse proxy (`api.dev.local`, `web.dev.local`) — use [Caddy](https://caddyserver.com) for that.
89+
- Framework detection.
90+
91+
All tracked as issues for v0.2+.
92+
93+
## License
94+
95+
MIT.

cert.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"time"
10+
)
11+
12+
// certCacheDir returns the directory where cached certs live.
13+
// Follows XDG Base Directory spec.
14+
func certCacheDir() string {
15+
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
16+
return filepath.Join(xdg, "httpsdev", "certs")
17+
}
18+
return filepath.Join(os.Getenv("HOME"), ".config", "httpsdev", "certs")
19+
}
20+
21+
// shouldRegenerate returns true if either cert file is missing or older than maxAge.
22+
func shouldRegenerate(certPath, keyPath string, maxAge time.Duration) bool {
23+
certInfo, err := os.Stat(certPath)
24+
if err != nil {
25+
return true
26+
}
27+
keyInfo, err := os.Stat(keyPath)
28+
if err != nil {
29+
return true
30+
}
31+
cutoff := time.Now().Add(-maxAge)
32+
return certInfo.ModTime().Before(cutoff) || keyInfo.ModTime().Before(cutoff)
33+
}
34+
35+
// ensureCert returns paths to a valid cached cert+key, regenerating via mkcert if needed.
36+
// extraHosts is added to the mkcert SAN list in addition to localhost, 127.0.0.1, ::1.
37+
func ensureCert(extraHosts []string) (certPath, keyPath string, err error) {
38+
dir := certCacheDir()
39+
if err := os.MkdirAll(dir, 0o700); err != nil {
40+
return "", "", fmt.Errorf("create cert cache dir: %w", err)
41+
}
42+
certPath = filepath.Join(dir, "localhost.pem")
43+
keyPath = filepath.Join(dir, "localhost.key")
44+
45+
if !shouldRegenerate(certPath, keyPath, 30*24*time.Hour) {
46+
return certPath, keyPath, nil
47+
}
48+
49+
if _, err := exec.LookPath("mkcert"); err != nil {
50+
return "", "", errors.New(
51+
"mkcert not found on PATH.\n" +
52+
"install it first:\n" +
53+
" brew install mkcert && mkcert -install",
54+
)
55+
}
56+
57+
hosts := append([]string{"localhost", "127.0.0.1", "::1"}, extraHosts...)
58+
args := append([]string{"-cert-file", certPath, "-key-file", keyPath}, hosts...)
59+
cmd := exec.Command("mkcert", args...)
60+
if out, err := cmd.CombinedOutput(); err != nil {
61+
return "", "", fmt.Errorf("mkcert failed: %w\n%s", err, string(out))
62+
}
63+
return certPath, keyPath, nil
64+
}

cert_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestShouldRegenerate_MissingFiles(t *testing.T) {
11+
dir := t.TempDir()
12+
certPath := filepath.Join(dir, "localhost.pem")
13+
keyPath := filepath.Join(dir, "localhost.key")
14+
15+
if !shouldRegenerate(certPath, keyPath, 30*24*time.Hour) {
16+
t.Fatal("expected regenerate=true when files missing")
17+
}
18+
}
19+
20+
func TestShouldRegenerate_FreshFiles(t *testing.T) {
21+
dir := t.TempDir()
22+
certPath := filepath.Join(dir, "localhost.pem")
23+
keyPath := filepath.Join(dir, "localhost.key")
24+
writeFile(t, certPath, "cert")
25+
writeFile(t, keyPath, "key")
26+
27+
if shouldRegenerate(certPath, keyPath, 30*24*time.Hour) {
28+
t.Fatal("expected regenerate=false when files fresh")
29+
}
30+
}
31+
32+
func TestShouldRegenerate_OldFiles(t *testing.T) {
33+
dir := t.TempDir()
34+
certPath := filepath.Join(dir, "localhost.pem")
35+
keyPath := filepath.Join(dir, "localhost.key")
36+
writeFile(t, certPath, "cert")
37+
writeFile(t, keyPath, "key")
38+
old := time.Now().Add(-40 * 24 * time.Hour)
39+
if err := os.Chtimes(certPath, old, old); err != nil {
40+
t.Fatal(err)
41+
}
42+
if err := os.Chtimes(keyPath, old, old); err != nil {
43+
t.Fatal(err)
44+
}
45+
46+
if !shouldRegenerate(certPath, keyPath, 30*24*time.Hour) {
47+
t.Fatal("expected regenerate=true when files older than max age")
48+
}
49+
}
50+
51+
func writeFile(t *testing.T, path, contents string) {
52+
t.Helper()
53+
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
54+
t.Fatal(err)
55+
}
56+
}
57+
58+
func TestCertCacheDir_UsesXDG(t *testing.T) {
59+
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdgtest")
60+
got := certCacheDir()
61+
want := "/tmp/xdgtest/httpsdev/certs"
62+
if got != want {
63+
t.Fatalf("got %q, want %q", got, want)
64+
}
65+
}
66+
67+
func TestCertCacheDir_FallsBackToHome(t *testing.T) {
68+
t.Setenv("XDG_CONFIG_HOME", "")
69+
t.Setenv("HOME", "/tmp/hometest")
70+
got := certCacheDir()
71+
want := "/tmp/hometest/.config/httpsdev/certs"
72+
if got != want {
73+
t.Fatalf("got %q, want %q", got, want)
74+
}
75+
}

go.mod

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module github.com/ArpitRajputGithub/httpsdev
2+
3+
go 1.26.5
4+
5+
require (
6+
github.com/charmbracelet/bubbletea v1.3.10
7+
github.com/charmbracelet/lipgloss v1.1.0
8+
github.com/fatih/color v1.19.0
9+
)
10+
11+
require (
12+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
13+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
14+
github.com/charmbracelet/x/ansi v0.10.1 // indirect
15+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
16+
github.com/charmbracelet/x/term v0.2.1 // indirect
17+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
18+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
19+
github.com/mattn/go-colorable v0.1.14 // indirect
20+
github.com/mattn/go-isatty v0.0.20 // indirect
21+
github.com/mattn/go-localereader v0.0.1 // indirect
22+
github.com/mattn/go-runewidth v0.0.16 // indirect
23+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
24+
github.com/muesli/cancelreader v0.2.2 // indirect
25+
github.com/muesli/termenv v0.16.0 // indirect
26+
github.com/rivo/uniseg v0.4.7 // indirect
27+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
28+
golang.org/x/sys v0.42.0 // indirect
29+
golang.org/x/text v0.3.8 // indirect
30+
)

0 commit comments

Comments
 (0)