Skip to content

Repository files navigation

LocalRun

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.

Adding a project folder to LocalRun, starting it from the menu-bar panel, opening it in a terminal, then loading it at notes.test in the browser

*.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

What it does

  • Named domainsmyapp.test instead of localhost: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 tiersmanual, localrun (restarts on crash), or always (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.

How it works

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

Why a DNS responder instead of /etc/hosts

/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.

Why it shares Caddy instead of fighting for :80

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.

Layout

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/.

Getting started

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.

Publishing @mindantic/localrun-mcp

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-checks

npm: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.

The token

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 token

At 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.

Platform support

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.

Adding a project

Use + Add in the tray and pick a folder. LocalRun auto-detects the run command and port, or you can commit a .localrun.json:

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

If no port is given, LocalRun reads it from your server's startup output.

Service kinds

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.

Keeping a project running

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

Development

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/release

Testing notes

e2e-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'

Two things that will bite you

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.

License

MIT