A macOS menu-bar app that turns any project folder into a named local service.
Add a folder → it runs at https://<name>.test, with a real certificate, managed from the tray.
No more remembering which project is on :3000 and which is on :5173.
*.test ──► DNS responder ──► 127.0.0.1
│
browser ──► Caddy :80/:443 ──► your project on 127.0.0.1:<port>
▲
daemon (DNS + Caddy + processes + IPC) ◄── Electron tray UI
▲
@mindantic/localrun-mcp ◄── Claude Code / Claude Desktop
- Named domains —
myapp.testinstead oflocalhost:3471. - Real local HTTPS — Caddy issues certificates from a locally-trusted CA, per project.
- Process control — start, stop, restart and rebuild from the menu bar; open the project in your terminal.
- Adopts what's already running — detects servers you started yourself and proxies them without restarting anything.
- Keep-running tiers —
manual,localrun(restarts on crash), oralways(a launchd agent that survives reboot). - Logs, env, health — live log tail, environment editor, and health-check status per project.
- Drivable by Claude — an MCP server exposes the daemon to Claude Code and Claude Desktop.
Three processes, one seam between them.
The daemon (daemon/) owns all state and privilege. It runs a DNS responder for the .test
TLD, manages Caddy, supervises child processes, polls health, and serves a JSON-RPC API.
The UI (ui/) is an Electron tray app. It holds no state — every action is an RPC call.
The MCP server (mcp/) wraps the same socket so an AI agent can manage sites.
The only seam between the daemon and the UI is newline-delimited JSON-RPC 2.0 over a unix
socket at $LOCALRUN_DIR/daemon.sock (default ~/.localrun/daemon.sock). The renderer never
touches that socket directly:
renderer → preload bridge → ipcMain → daemon-client.ts → unix socket → daemon
/etc/hosts has no wildcards, so every new project would mean another privileged edit. Instead a
small DNS responder answers *.test with 127.0.0.1, and /etc/resolver/test points macOS at it.
Adding a project then needs no root at all.
The responder binds an unprivileged high port (default 15353) on loopback, and the resolver
file names that port explicitly — man 5 resolver documents a port directive. This isn't a
detail you can skip: macOS lets an unprivileged process bind a privileged port on the wildcard
address but not on a specific one, so udp 127.0.0.1:53 fails with EACCES while
udp 0.0.0.0:53 succeeds. Binding the wildcard would expose the resolver to your LAN, so LocalRun
takes the high port instead.
If you already run Caddy (say via Homebrew), LocalRun does not start a competing instance. Two
processes can both bind :80 via SO_REUSEPORT, both look healthy in lsof, and one silently
wins every request. Instead LocalRun writes a fragment to ~/.localrun/caddy/localrun.caddy, adds
a single import line to your Caddyfile, and reloads through the admin API. No sudo, and your own
sites keep working.
| Path | What |
|---|---|
daemon/ |
Headless Node daemon, ESM. Owns all state and privilege. |
ui/ |
Electron 33 + React 18 + electron-vite. Main/preload are CJS. |
mcp/ |
@mindantic/localrun-mcp — MCP server so Claude can drive LocalRun. The only publishable package. |
website/ |
Next 14 marketing site and manual, static export. |
resources/caddy/<platform>/caddy |
Bundled Caddy binaries (gitignored — run pnpm caddy:fetch). |
ui/logo/ |
Source logo assets; input to the icon pipeline. |
scripts/ |
e2e-real.sh, e2e-cases.mjs, verify-packaged-app.sh, build-installers.sh, fetch-caddy.sh, gen-icons.sh. |
docker/ |
Linux images, kept for the unit suite only — see Platform support. |
State lives in $LOCALRUN_DIR (default ~/.localrun): projects.json, settings.json,
daemon.sock, daemon.pid, caddy/Caddyfile, and logs/.
pnpm install
pnpm caddy:fetch # bundle Caddy (copied from Homebrew)
pnpm --filter @localrun/daemon dev # run the daemon alone
pnpm --filter @localrun/ui dev # launch the tray app (spawns a daemon if none is up)On first launch the app detects that *.test resolution and the local CA aren't configured and
offers a one-click Install. That shows a native password prompt, then writes
/etc/resolver/test and trusts the local CA. Nothing to paste. The same steps are available over
IPC as bootstrap.status and bootstrap.install if you prefer to drive it yourself.
mcp/ is the only publishable package. Everything else is private: true.
pnpm npm:verify # build, pack, install the tarball in a temp project, drive it over stdio
cd mcp && pnpm publish --access public --no-git-checksnpm:verify is the gate — it proves the artifact npm would publish actually runs, rather than
merely that it packs. It fails if the tarball is missing LICENSE, README.md, dist/index.js or
dist/THIRD-PARTY-NOTICES.txt, if source maps or tests leak in, if the tarball will not install,
or if the installed binary does not answer initialize and list all 19 tools.
The bundle inlines its dependencies, so dist/THIRD-PARTY-NOTICES.txt is generated from esbuild's
metafile at build time and reproduces all 8 bundled licences in full — MIT and BSD-3-Clause both
require the notice to travel with the copy. build.mjs fails the build if that file comes out empty.
npm login and npm config set …_authToken both leave the token in plaintext in ~/.npmrc.
The file is mode 600, so other user accounts cannot read it — but every process running as you
can, including any postinstall script of any package you install. Publish tokens have been stolen
that way.
So the token goes in the macOS Keychain instead, and is only ever an environment variable:
scripts/publish-mcp.sh --store # prompts, hidden input, saves to the login Keychain
scripts/publish-mcp.sh --dry-run # verifies + packs, uploads nothing
scripts/publish-mcp.sh # verifies, then publishes
scripts/publish-mcp.sh --forget # delete the stored tokenAt publish time the script reads the token into $NPM_TOKEN and writes a temp npmrc containing
//registry.npmjs.org/:_authToken=${NPM_TOKEN} — the reference, not the secret. npm expands it
in memory; the temp file is deleted on exit either way. Verified on the wire: npm sends
Authorization: Bearer <token> from the environment variable alone.
Use a granular token scoped to @mindantic/localrun-mcp with a short expiry, not a full-access one.
The scope @localrun must already exist on npm and be owned by the publishing account, or
publish returns a 404 that reads like a network error.
macOS only. Windows is not supported and there are no plans for it.
Linux is unfinished and deliberately not shipped — no .deb, no .AppImage. The daemon core is
portable (the DNS responder, Caddy, the process manager, and a systemd branch in
service-kind.ts), but four things are macOS-only: trusting the local CA, the always keep-running
tier (launchd plists), the Open-in-Terminal button, and the MCP client config paths. The daemon now
refuses the always tier off macOS with a clear message, and caTrusted() reports false rather
than guessing from a file's existence. The platform/ abstraction and docker/ images stay in the
tree as the head start if that changes.
Use + Add in the tray and pick a folder. LocalRun auto-detects the run command and port, or you
can commit a .localrun.json:
If no port is given, LocalRun reads it from your server's startup output.
Not everything listening on a port is something LocalRun can control. The daemon classifies each service and tells the UI what it may offer — the renderer never guesses.
| Kind | Badge | What | Start / Stop |
|---|---|---|---|
project |
OWN |
LocalRun spawned it | full control, logs captured |
adopted |
EXT |
LocalRun proxies a process it didn't start | stop kills the tree; start needs an inferred command |
system |
SYS |
launchd/systemd-supervised | routed through the supervisor |
proxy |
FWD |
port-only, or a Docker port proxy | neither — killing docker-proxy won't stop the container |
Supervised services are never signalled directly — launchd would just restart them and you'd
see a service that refuses to die. Those go through launchctl.
keepRunning is a single dial, orthogonal to what the project actually runs.
| Tier | Supervised by | Survives quit / crash / reboot |
|---|---|---|
manual |
you | no / no / no |
localrun |
the daemon | no / yes / yes, via the daemon's own LaunchAgent |
always |
launchd, one agent per project | yes / yes / yes |
pnpm test # 543 daemon + 83 mcp + 12 ui tests, no privileges needed
pnpm typecheck # all packages
pnpm build # all packages
pnpm --filter @localrun/ui e2e # Playwright against the real Electron app
pnpm e2e:real # production path; exits 77 (SKIP) if setup is incomplete
pnpm e2e:cases # live integration cases against a running daemon
bash scripts/gen-icons.sh # regenerate tray + app icons from ui/logo
pnpm --filter @localrun/ui package:dir # build LocalRun.app into ui/release
pnpm verify:app # assert the bundle is actually runnable
pnpm installers # .dmg + .AppImage + .deb into ui/releasee2e-real.sh uses no --resolve and no sandbox ports, and skips loudly (exit 77) rather than
passing when /etc/resolver/<tld> disagrees with the daemon's bound port. An earlier set of gates
overrode the DNS, HTTP and HTTPS ports to unprivileged ones — convenient, but it meant the privileged
path real users hit was never exercised, which is how a completely non-functional build once shipped
with every gate green.
Leftover processes are the main source of confusing failures. The app spawns the daemon detached, so it deliberately outlives the UI. A stale daemon's Caddy will co-bind the test port and answer empty 200s, which reads as an assertion failure rather than an environment problem. To clean up by hand:
pkill -f 'localrun/resources/caddy' ; pkill -f 'daemon/src/index.ts'The daemon is ESM; the UI's main and preload are CJS. The daemon has no __dirname and no
require() — use fileURLToPath(import.meta.url) and keep .js extensions on imports.
@types/node declares __dirname globally, so typecheck will not catch this; it fails at
runtime. The UI main process has the opposite rule.
Assets must survive the asar. files: ["out/**/*"] excludes anything outside out/. Ship
runtime assets via extraResources and resolve them through ui/src/main/assets.ts, which
branches on app.isPackaged.
CLAUDE.md documents the rest — the full set of platform landmines, validation rules, and the
reasoning behind each design decision. Read it before changing the daemon.
MIT

{ "name": "my-scraper", "runCommand": "npm run dev", "buildCommand": "npm run build", "port": 3000, "tls": "auto", "health": "/health" }