lewp

lewp CLI reference

One binary, two roles: the CLI client you type commands into, and the launchd-managed daemon that does the routing.

lewp <command> # CLI client
lewp daemon # launchd-managed daemon

Most commands talk to the daemon over a local control socket at ~/Library/Application Support/lewp/control.sock.

Build and Install

curl -fsSL https://raw.githubusercontent.com/scottwater/lewp/main/install.sh | bash
go install github.com/scottwater/lewp/cmd/lewp@latest
bin/build
bin/install
bin/reinstall

install.sh downloads the latest GitHub release archive for the current Mac architecture and installs lewp into ~/.local/bin by default. Set LEWP_INSTALL_DIR to install elsewhere. go install builds from source. bin/install installs a local checkout build; bin/reinstall runs build then install.

Commands

lewp setup [--suffix S] [--allow-domain-mirror] [--log-requests errors|all] [--start]
lewp system start|stop|status|restart
lewp system uninstall [--yes]
lewp lease [--root R] [--name N] [--host H] [--auto-suffix] [--reset] [--json|--shell]
lewp alias add <host> [--json]
lewp alias remove <host>
lewp alias list [--json]
lewp init [--root R] [--name N] [--host H] [--force]
lewp info [--name N] [--host H] [--kind K] [--json|--shell|--port]
lewp move --from <path> [--json]
lewp port [--name N] [--json|--shell]
lewp release [--route | --name N] [--forget] [--dry-run] [--json]
lewp release --path P [--recursive] [--route | --name N]
             [--forget] [--dry-run] [--json] [--yes|-y]
lewp release --host H [--forget] [--dry-run] [--json]
lewp release --port N [--forget] [--dry-run] [--json]
lewp release --pick [--forget] [--dry-run] [--yes|-y]
lewp list [--all] [--json]
lewp suffix list
lewp suffix remove S
lewp doctor
lewp logs [--lines N] [--grep TEXT] [--follow] [--path]
lewp completion bash|zsh|fish
lewp version [--detailed]
lewp upgrade
lewp daemon [--log-requests errors|all]

Every command accepts --help (alias -h) for command-specific usage, flags, and examples. lewp, lewp help, and lewp --help print the top-level help. An unknown command prints the top-level help to stderr and exits 2.

lewp — local domain router and port leaser for parallel development

Lewp leases a stable loopback port and a predictable <instance>.<root>.lewp
hostname for the current directory, then reverse-proxies browser traffic to a
process you start yourself. It routes and leases only; it never starts apps.

Usage:
  lewp <command> [flags]

Commands:
  setup      Install the .lewp DNS resolver, local CA, and launchd service
  system     Manage the daemon: start|stop|status|restart|uninstall
  lease      Lease a stable port and managed hostname for the current directory
  alias      Manage extra hostnames for the current route
  init       Generate a .lewp.local.toml identity file for the current directory
  info       Show routes and bare ports registered for the current directory
  move       Move a route from another directory to the current directory
  port       Lease a bare internal port without a hostname
  release    Release routes and bare ports by path, host, or port
  list       List active routes and their health
  suffix     List or remove custom managed suffixes
  doctor     Diagnose daemon state, DNS, and CA trust
  logs       Show or tail the daemon logs
  completion Print a shell completion script (bash|zsh|fish)
  version    Print version
  upgrade    Replace this binary with the latest GitHub release
  daemon     Run the daemon in the foreground (normally launchd-managed)

Configuration is resolved highest priority first: flags (--root, --name, --host), then environment (LEWP_ROOT, LEWP_NAME, LEWP_HOST), then the nearest .lewp.local.toml up the tree, then inference (parent dir → root, current dir → name). The .lewp.local.toml file is meant to stay local/uncommitted — run lewp init to generate one.

lewp setup

One-time macOS setup for .lewp routing and HTTPS. It writes /etc/resolver/lewp (via sudo when needed), creates the local CA under ~/Library/Application Support/lewp/, installs the launchd plist, and trusts the CA with the macOS security tool. Run lewp system start afterward.

lewp setup [--suffix <suffix>] [--allow-domain-mirror] [--start]
           [--log-requests errors|all]

.lewp is built in and always installed. --suffix S additively installs a resolver for an owned public dev suffix — useful when an OAuth provider rejects private TLDs. Custom suffixes default to safe-subtree mode; domain-mirror mode must be opted into with --allow-domain-mirror because it shadows public DNS locally.

lewp setup --suffix local.todoordie.com                          # safe-subtree
lewp setup --suffix localkickofflabs.com --allow-domain-mirror   # domain mirror

--log-requests errors|all sets how much request traffic the daemon logs (persisted in the launchd plist). The default, errors, logs only failed requests so a steady stream of successful HMR/SSE/websocket traffic cannot grow the log without bound. Restart the daemon for a change to take effect.

DNS=resolver-file
HTTPS=enabled
LAUNCHD=/Users/scott/Library/LaunchAgents/dev.lewp.daemon.plist
RESOLVER=/etc/resolver/lewp
CA=/Users/scott/Library/Application Support/lewp/ca.pem
LOGS=/Users/scott/Library/Logs/lewp

lewp system

Manage the macOS LaunchAgent: start | stop | restart | status | uninstall. Each action runs the matching launchctl command and prints it. If start sees bootstrap status 5 because the job is already loaded, it falls back to launchctl kickstart -k. start and restart then wait up to five seconds for the daemon to answer on its control socket; if it does not, the command fails and prints the daemon log path and launchd diagnostics.

lewp system start
lewp system status
lewp system uninstall --yes

uninstall is destructive, so it first prints an affected-file summary (launchd job, keychain trust, resolver files, CA material it keeps) before doing any work, then asks for confirmation. Non-interactively it refuses unless you pass --yes (or -y), so an unattended uninstall can never silently tear down the integration.

lewp daemon is running

lewp suffix

List and remove custom managed suffixes. .lewp is built in and cannot be removed. Custom suffixes are added with lewp setup --suffix S; suffix remove S removes the suffix from config and deletes its resolver file after confirming the resolver is lewp-owned.

lewp suffix list
lewp suffix remove local.todoordie.com
SUFFIX                 MODE
lewp                   built-in
local.todoordie.com    safe-subtree
localkickofflabs.com   domain-mirror

lewp lease

Lease a routed app port and a lewp-managed host for the current directory. This is the command for creating a route. Re-running from the same directory returns the same port and host. By default the host is under .lewp; root and name are inferred from the directory layout unless overridden.

lewp lease
lewp lease --root atlas --name feature-1
lewp lease --host atlas.lewp
lewp lease --json
lewp lease --shell
PORT=42137
URL=http://feature-1.atlas.lewp
HTTPS_URL=https://feature-1.atlas.lewp
HOST=feature-1.atlas.lewp
STATE=new
HOST_KIND=instance

URL is always http://<host> and HTTPS_URL is https://<host>; both are emitted whenever the lease has a routable host. STATE is new, reused, or conflict-renamed; HOST_KIND is instance, apex, or custom. --shell prefixes assignable values with export and omits descriptive lines, so eval "$(lewp lease --shell)" sets only the variables you want.

export PORT=42137
export URL=http://feature-1.atlas.lewp
export HTTPS_URL=https://feature-1.atlas.lewp
export HOST=feature-1.atlas.lewp

Identity is discovered from the first source that provides each of root/name/host: flags, then LEWP_* environment, then the nearest .lewp.local.toml, then path/git-worktree inference. Once you pass --root, --name, or --host, that value is remembered for the directory. Use --reset to clear a bad override without throwing away the port or history.

lewp lease --reset               # re-infer everything, keep port + history
lewp lease --reset --root atlas  # keep root, re-infer name and host

An inferred host that conflicts with another directory receives a stable deterministic suffix. An explicit --host that is already assigned elsewhere fails with the owning path and a cleanup command you can run from anywhere; pass --auto-suffix to take a deterministic suffix instead of failing.

lewp release --path "/work/old app" --route --forget

--static <dir> serves files from a directory instead of proxying, so build output such as a compiled JavaScript bundle gets a stable URL with nothing running. URL paths map straight onto the directory, aliases serve the same files, and the setting is remembered like --host. --no-static switches back to proxying on the same port and host.

cd ~/work/js
lewp lease --host kickoffjs.lewp --static dist
curl https://kickoffjs.lewp/3.0.0/kol.js   # serves dist/3.0.0/kol.js

Requests can't leave the directory: .. segments and symlinks pointing outside it are refused. Dotfiles and dot-directories (.env, .git/) always return 404, even through a symlink. A directory serves its index.html or 404s; directories are never listed. Only GET and HEAD are allowed, and responses send Cache-Control: no-cache so a rebuild shows up on the next reload. --static needs a daemon that knows about it; if the daemon is older than the CLI, lease fails and asks you to run lewp system restart.

lewp alias

Attach extra hostnames to the current directory’s active route. Aliases reuse the route’s existing port and process — they do not allocate another port, create another primary route, or start anything. Run lewp lease first; alias add requires an active route.

lewp alias add tags.feature-1.atlas.lewp
lewp alias add '*.feature-1.atlas.lewp'
lewp alias list
lewp alias remove tags.feature-1.atlas.lewp

Use aliases when one app process serves multiple hostnames, such as Rails subdomain routing. Wildcards match exactly one label: *.app.lewp matches tags.app.lewp, but not app.lewp and not api.tags.app.lewp. Aliases that conflict with an existing active host are rejected with the conflicting path.

PORT=42137
URL=http://tags.feature-1.atlas.lewp
HTTPS_URL=https://tags.feature-1.atlas.lewp
HOST=tags.feature-1.atlas.lewp
STATE=new
HOST_KIND=alias

lewp init

Generate a .lewp.local.toml for the current directory so its identity is explicit and stable regardless of how the folder is laid out. init only writes the file — it never contacts the daemon, allocates a port, or leases a route. Values come from flags first, then path inference.

lewp init
lewp init --root atlas --name feature-1
lewp init --host atlas.lewp
lewp init --force
root = "atlas"
name = "feature-1"
host = "atlas.lewp"

The file is meant to stay uncommitted; init prints the .git/info/exclude line that keeps it out of version control. Without --force, init refuses to clobber an existing .lewp.local.toml.

lewp info

Show routes and bare ports registered for the current directory. info reads existing registry data only — it never infers, allocates, or mutates a route or port. Default output is the same table shape as lewp list, scoped to this directory.

HOST                       NAME       KIND   PORT   STATE  PATH
feature-1.atlas.lewp       feature-1  route  42137  up     .../atlas/feature-1
tags.feature-1.atlas.lewp  feature-1  alias  42137  up     .../atlas/feature-1
-                          vite       port   42138  down   .../atlas/feature-1

--name filters by logical name; --host filters by exact host; --kind filters by route, alias, or port. --port emits only the port digits (requires exactly one match); --shell emits eval-safe exports for exactly one match. If nothing is registered, info exits non-zero and points you at lewp lease, lewp port, or lewp move.

PORT="$(lewp info --host feature-1.atlas.lewp --port)"
eval "$(lewp info --host feature-1.atlas.lewp --shell)"
eval "$(lewp info --kind route --shell)"

lewp move

Move an existing route from another directory to the current directory, keeping the same primary host, aliases, wildcards, and port. Use this after relocating or renaming a project folder so its stable URLs follow it. --from is required and names the directory that currently owns the route.

lewp move --from ~/projects/atlas/old-feature
lewp move --from ~/projects/atlas/old-feature --json

The move is atomic: the source directory is left with no route and the current directory becomes the owner with the same port and hosts. The port is never reallocated. If the source has no active route, or the current directory already owns one, move fails and changes nothing.

lewp port

Lease a stable bare internal port with no hostname or proxy route — useful for sidecar processes (asset bundlers, internal APIs) that need a stable port but no .lewp host. With no --name, port leases under the default name "port", so repeated calls from the same directory return the same number.

lewp port
lewp port --name vite
eval "$(lewp port --name vite --shell)"
export VITE_RUBY_PORT="$(lewp port --name vite --json | jq -r .port)"

port prints the same env-style output as lease minus the host fields. To free a named bare port in the current directory, use lewp release --name <name>; plain lewp release frees the route and every bare port at once. To target the current owner of a known number from any directory, use lewp release --port <number>.

lewp release

Release routes and bare ports by path, registered host, or numeric port. Plain lewp release selects the current directory and releases its route, aliases, wildcard hosts, and bare ports. Use --path to clean up another directory, including one that no longer exists.

lewp release
lewp release --path /work/deleted-worktree
lewp release --host app.work.lewp
lewp release --host '*.app.work.lewp'
lewp release --port 42137

A host selector matches an exact registered primary host, alias, or wildcard; quote wildcards so the shell cannot expand them. A numeric port can belong to a route or a bare port.

--path accepts an absolute path or resolves a relative one against the current directory, then cleans . and .. lexically; lewp does not resolve symlinks or require the path to exist. --recursive selects registered descendants at path-component boundaries, so /work/app includes /work/app/admin but not /work/application. Preview the complete plan with --dry-run before changing several paths.

lewp release --path /work/worktrees --recursive --dry-run
lewp release --path /work/worktrees --recursive --yes
lewp release --path /work/worktrees --recursive --yes --json

A recursive human mutation prints the full plan. Interactive runs prompt unless you pass --yes; noninteractive and JSON recursive mutations require --yes. Recursive JSON emits one object without a human preview or prompt. Exact path, host, and port mutations do not prompt. With --yes, human output still prints the preview. A recursive change from / follows the same confirmation rules; run a dry run first.

Use --route to select only a path’s route and --name to select one named bare port; both need a path selector and cannot be combined, and --recursive needs an explicit --path. Without a scope, a path selects its route and all bare ports. --forget deletes matching registry history; release otherwise keeps it. With a path, --forget also reaches released history, so it works after you released an item or deleted its folder.

lewp release --path /work/app --route
lewp release --path /work/app --name vite
lewp release --path /work/deleted-worktree --forget

Use --pick to choose one or more registered paths from an interactive checklist and release everything at each. Each row shows the path’s main host and the end of its folder; deleted paths are marked stale. Move with the arrow keys or j/k, toggle with space, toggle all with a, and press enter to continue; q or Esc cancels. Lewp previews the combined plan and asks for confirmation unless you pass --yes. With --forget, the checklist also includes released history. Each path applies atomically; if one fails, lewp reports how many paths it released first. --pick requires a terminal and cannot be combined with --path, --host, --port, --route, --name, --recursive, or --json.

lewp release --pick
lewp release --pick --forget
lewp release --pick --dry-run

Host and numeric-port selectors target current owners only. Lewp counts one item per logical allocation: a route is one item even with aliases, wildcards, or several historical ports, and so is one named bare port. Output reports matched, released, and forgotten counts (Planned releases and Planned forgets for a dry run). Dry runs never change the registry. Lewp applies each plan atomically and refuses it with "release plan changed; rerun the release command" if registry state changes. A selector with no matches exits 0, so repeated releases are safe no-ops.

lewp list

List registry entries (routes, aliases, and bare ports) with their health. States are derived from TCP checks, or for static routes, whether the directory exists. The default list hides released routes; --all includes registry history. KIND is route, alias, or port; a bare port has no host, so HOST shows "-".

HOST                       NAME       KIND   PORT   STATE  PATH
feature-1.atlas.lewp       feature-1  route  42137  up     .../atlas/feature-1
tags.feature-1.atlas.lewp  feature-1  alias  42137  up     .../atlas/feature-1
-                          vite       port   42138  down   .../atlas/feature-1

States: up (TCP connect succeeds, or a static route's directory exists), down (connect fails, or the directory is missing), stale (registered path no longer exists), released (route was explicitly released). --json emits a JSON array (always an array, never null), each entry with host, port, state, path, kind, root, and name.

lewp list --json | jq '.[] | select(.kind == "alias")'

lewp doctor

Diagnose daemon state, DNS, and CA trust. It reports daemon and control-socket reachability, resolver configuration, and whether the local CA is trusted in the keychain. It also compares the binary launchd is configured to run against the CLI you are running now.

daemon: ok
control socket: ok
https: configured (local CA present)
keychain: trusted
cli binary: /usr/local/bin/lewp
launchd plist: ~/Library/LaunchAgents/dev.lewp.daemon.plist
installed program: /usr/local/bin/lewp
installed program matches this CLI
current version: 0.5.0
installed version: 0.5.0
daemon log: ~/Library/Logs/lewp/daemon.err.log

When the daemon is up, doctor resolves probe.lewp through the macOS system resolver — the same path browsers use — and warns (with sudo killall -HUP mDNSResponder as the recovery hint) when the name does not resolve. If launchd runs a different binary than the current CLI it prints a mismatch line pointing at bin/reinstall and lewp system restart.

mismatch: launchd runs /old/bin/lewp but this CLI is /usr/local/bin/lewp
  — run bin/reinstall and lewp system restart

lewp logs

Show or tail the launchd-managed daemon’s logs under ~/Library/Logs/lewp. By default the daemon’s stdout carries a request line only for failed requests; startup failures land on stderr. lewp logs only reads files — it never starts the daemon.

lewp logs                        # last 200 lines of each log
lewp logs --lines 500
lewp logs --grep error           # case-insensitive filter
lewp logs --grep 502 --lines 50
lewp logs --follow               # stream new output (alias: -f)
lewp logs --path                 # print the two log file paths

--grep TEXT keeps only lines containing TEXT (case-insensitive); --lines then bounds the matching lines, so --grep TEXT --lines N behaves like grep TEXT | tail -n N. To log every proxied request, run lewp setup --log-requests=all.

lewp completion

Print a static shell completion script to stdout for bash, zsh, or fish. The script is self-contained and never contacts the daemon. A missing or unsupported shell argument exits 2 with guidance.

lewp completion bash > /usr/local/etc/bash_completion.d/lewp
lewp completion zsh  > "${fpath[1]}/_lewp"   # then restart zsh
lewp completion fish > ~/.config/fish/completions/lewp.fish

lewp version

Print the public version. Add --detailed for development/build metadata. version, --version, and -v are equivalent. The version is injected at link time by bin/build; an un-stamped go build reports "dev".

lewp version --detailed
lewp version 0.5.0
commit:  0a778ce
built:   2026-06-27T23:08:00Z
go:      go1.25.1

lewp upgrade

Download the latest GitHub release archive for this Mac, extract the lewp binary, and atomically replace the executable currently running the command. If the current binary already matches the latest release, upgrade reports it is up to date and exits 0.

lewp upgrade
lewp system restart   # so launchd runs the new binary

lewp daemon

Run the daemon in the foreground. Normally launchd starts this command. The daemon owns the SQLite registry, the Unix control socket, HTTP/HTTPS proxy listeners from launchd, and SNI certificate minting from the persisted local CA.

The daemon logs failed requests (proxy errors and statuses ≥ 400) to stdout, captured in daemon.out.log; inspect it with lewp logs. Pass --log-requests=all to log every proxied request instead. Startup errors are written to stderr (daemon.err.log).

.lewp error pages

The proxy serves debuggable HTML rather than blank gateway errors for the two common failure modes. Both pages share a small, framework-neutral inline stylesheet (no external assets, no web fonts, no JavaScript) that renders in light and dark mode.

Registered but not responding: when a host is leased but its target port is closed, the proxy returns 502 with the host, loopback target, project path, root/name, last-seen time, release state, a copyable PORT=<n> <your dev command> start command, and lewp list / lewp doctor hints.

Unregistered managed host: when a host under .lewp or a configured suffix has no route, the proxy returns 404 with parsed labels and next steps. Unmanaged hosts get a plain 404. The host is HTML-escaped so a crafted hostname cannot inject markup.

Missing static directory: when a --static route's directory can't be opened, the proxy returns 502 with the directory, the error, the project path, and the lewp lease --static <dir> command to re-point it.

Browser URLs

DNS resolves all .lewp names and configured custom suffixes to loopback. The proxy routes by full registered host. HTTPS uses lewp’s local CA, created and trusted by lewp setup, which mints certificates for .lewp SNI names and safe-subtree custom suffixes. Domain-mirror suffixes always route over HTTP.

http://<host>
https://<host>   # .lewp and safe-subtree custom suffix hosts

Safari and Chromium browsers (Chrome, Brave, Arc, Edge, Helium) trust the macOS system keychain, so https://<host> works as soon as lewp setup has run. Firefox is not supported in V1 — it uses its own NSS trust store, so use http://<host> in Firefox or switch to Safari/Chromium for HTTPS.

Troubleshooting

Daemon not running: lewp daemon is not running → run lewp system start.

Target app not running: lewp list shows the route as down, and the browser shows a lewp debug page with host, target, path, and hints. Start your app manually with the leased PORT.

HTTPS warnings: run lewp setup, restart the browser if it cached trust state, or run lewp system uninstall to remove lewp CA trust, the LaunchAgent plist, and resolver file.

Port and host conflicts: lewp never takes another current allocation. An inferred primary host conflict receives a deterministic suffix; an explicit --host conflict reports its current owner. Conflicting aliases and wildcards report the registered owner path. Release a stale owner from any directory with lewp release --path "/work/old app" --route --forget; the old path does not need to exist.