Supbuddy docs
Run multiple Supabase projects at once on one Mac, each with its own custom local domain.
Getting started
There are two ways to run Supbuddy. Use the macOS desktop app (steps below), or the command-line interface, which runs on macOS and Linux. For the CLI, install it with npx supbuddy@latest and jump to Command-line interface. The app and the CLI share the same state, so you can use either or both.
1. Install
Download the latest .dmg from the download page. Drag Supbuddy.app into /Applications and launch it. Supbuddy is signed and notarized; macOS will not show a Gatekeeper warning. Requires an Apple Silicon Mac (M1/M2/M3/M4, arm64). The desktop app is macOS-only in v2, but the headless CLI runs on Linux too. See Command-line interface.
2. Trust the local Certificate Authority
Caddy mints its local CA the first time the proxy starts — no mapping required, so you can trust it before you add anything. With the proxy running, open the app and click Install (the first-launch prompt, or Settings → Network later). Supbuddy adds the CA (Caddy's internal PKI at ~/Library/Application Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt) to your System keychain via sudo security add-trusted-cert; macOS asks for your password once. (On macOS 15+ that system-wide step is no longer permitted to a background helper, so Install falls back to per-user trust — see below.) Caddy does not self-install trust (the generated Caddyfile sets skip_install_trust), so this button is what makes the padlock green — fully quit and reopen your browser afterward to pick it up. Every Supbuddy domain then gets HTTPS with no per-domain prompts or warnings. (On Windows the install is manual: Supbuddy shows the PowerShell Import-Certificate … -CertStoreLocation Cert:\LocalMachine\Root command to run as Administrator.)
On macOS 15 (Sequoia) and later, Install trusts the CA for your user account. Apple now routes system-wide trust changes through an authorization dialog that macOS refuses to show to a background helper — being root is no longer enough, and the attempt comes back as SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible. Supbuddy still tries the system-wide install first (it works on Sonoma and earlier, and covers every user on the machine); when macOS refuses it, Supbuddy adds the root to your login keychain instead and macOS shows "You are making changes to your Certificate Trust Settings" — confirm with your login password. Browsers honour user-domain trust exactly the same way, and Uninstall removes the root from both keychains. If you dismiss that dialog, Supbuddy pins the exact command on screen so you can run it yourself — without sudo, which would land back in the domain macOS just refused:
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \
~/Library/Application\ Support/Supbuddy/caddy-data/caddy/pki/authorities/local/root.crt
Caddy names its root by year, so each yearly rotation (or a data wipe) leaves a same-name root behind with a different key. On every Install, Supbuddy first removes any stale Caddy Local Authority roots whose fingerprint doesn't match the current one, then adds the current root — leftover mismatched roots otherwise make Firefox-family browsers fail with SEC_ERROR_BAD_SIGNATURE. This cleanup runs against whichever keychain the install targets, and trust detection reads both the System and login keychains, so a root trusted per-user still reports as installed.
Firefox, Zen, and Brave keep their own certificate store that Supbuddy can't reach (they don't consult the System keychain). After a CA change, either delete any stale Caddy Local Authority entries from the browser's own certificate manager and re-import the new root, or — on Firefox/Zen — set security.enterprise_roots.enabled to true in about:config so the browser reads the System keychain.
If Supbuddy detects an AI tool that ships its own JavaScript runtime (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, etc.) it will also offer to enable Bundled-runtime trust in the same first-run prompt. Those tools don't read the system Keychain (they carry their own Mozilla CA bundle), so without this setup the first OAuth/MCP connection to a *.test URL fails with unable to get local issuer certificate. Enable it once and Supbuddy keeps it in sync (including across yearly Caddy CA rotation). See the Bundled-runtime trust section under Settings → General for details.
If you skip the prompt, you can re-trigger it any time from the Settings → Network tab.
3. Add your first project
Click Add project in the Configure tab and pick a project root folder (the one with package.json and/or supabase/config.toml). Supbuddy scans it and creates auto-mapped subdomains based on what it finds:
- Supabase Kong →
api.<project>.test - Supabase Studio →
studio.<project>.test - Supabase Inbucket / Mailpit →
mail.<project>.test - Each detected app (Next.js, Vite, etc.) →
<app-name>.<project>.test
The default TLD is .test. You can change it project-wide in Settings → General → Default TLD.
.localis fine again, from 3.5.18. Earlier versions made every managed domain resolve slowly — a name resolved in milliseconds once and then stalled five seconds per concurrent lookup, socurl, a singlefetchanddigall looked healthy while any page issuing several requests at once failed with what looked like a connect timeout on the proxy. The advice used to be to move off.local, on the grounds that macOS reserves it for multicast DNS (RFC 6762). That was only half right, and the half that mattered was ours: Supbuddy's DNS server answered onlyAfor managed domains and forwarded the IPv6 (AAAA) lookup to the upstream resolver, which never answers for a local name — so no reply was sent at all and the client waited out its own timeout. A name on a non-reserved suffix stalled identically (5003 ms against.local's 5002 ms), which is what proved the suffix was not the cause. Supbuddy now answersAAAAitself with::1; because that is a positive answer it also satisfies macOS's multicast rule, so.localresolves in single-digit milliseconds like any other suffix. (A client that prefers IPv6 is refused on[::1]:443and falls back to IPv4 in about 3 ms — there is deliberately no IPv6 redirect, because one was tried and it silently broke the backend HTTPS port.) There is no need to rename your domains.doctorstill shipsdns-local-tld-mdns-stallas a canary — if it fires on 3.5.18 or newer, checksupbuddy versionfirst, since an updated app can still be attached to an older daemon. One thing the suffix can still cause is the opposite symptom — the name not resolving at all on Sonoma and later, because mDNS owns the namespace by a path/etc/resolverdoes not govern. That is rarer, it is not slowness, and the only fix is a different TLD; see the PROXY ERROR banner.
4. Start the proxy
Toggle the project on. Supbuddy starts Caddy on port 8443 (HTTPS) and starts its built-in DNS server on port 5353. If you want real ports 80/443 instead of 8080/8443, enable port forwarding in Settings → Network. Supbuddy inserts a pfctl redirect rule into /etc/pf.conf (asks for sudo once) and reports whether the redirect is actually being enforced via a live 443 probe — not merely that the rule is on disk. If port forwarding is on but 443 won't connect, see Port forwarding is on but 443 won't connect.
Enforcement has a third state: unknown. The 443 probe only means something when something is listening behind the redirect, so if the HTTPS port has no listener — most often when no mapping is enabled yet, which generates a Caddyfile with no site blocks — Supbuddy reports enforcement as unknown rather than off. In that state it shows no "not enforcing" badge, no red banner, and never asks for your password: a redirect it cannot observe is not a redirect it can call broken.
If the one-time sudo prompt is cancelled or fails, Supbuddy no longer aborts the start: Caddy still comes up and HTTPS keeps working on the high port (8443), and the proxy shows a degraded error state with a Retry so you can re-run the privileged setup. The CA is still generated in this state. You have three minutes to answer an admin prompt (older builds gave up after 30 seconds and then discarded the result of a password typed later, reporting work that had actually succeeded as failed).
Core concepts
Four things to understand:
- Project: a folder you registered. Holds detected apps (Next.js, Vite, etc.), detected services (Supabase stack, Docker Compose services), and a list of mappings.
- Mapping: a domain → port pair (e.g.
api.acme.test → 54321). Auto-generated mappings are tied to a detected service or app; you can also create manual ones. - Isolation mode: per-project. One of:
thin(lightweight, the default for newly registered projects): still your host Docker (no nested containers, no DinD), but Supbuddy gives each project its own port block and a unique Composeproject_id, written into that project'ssupabase/config.toml. That's what lets several Supabase projects run at once on the shared daemon, each reached by name (api.<project>.test,studio.<project>.test). Apps bind a per-project loopback IP (127.0.0.2, 127.0.0.3, …) so every project's dev servers keep their canonical ports — each project gets its own:3000. Start dev servers withsupbuddy run -- <dev command>so they bind that IP. Supbuddy owns those config.toml keys while the project isthinand restores them the moment you switch back tohost.host: everything shares127.0.0.1and the stock ports. Dev-server ports collide across projects, and only one host-mode Supabase project can run at a time (the standardsupabase startconstraint). Usehostonly when the project's Supabase stack is already running on the host independently of Supbuddy (you runsupabase startyourself and don't want Supbuddy re-portingconfig.toml). MCP registration (register_project) detects that case and keeps such projects onhostautomatically; in the app's Add-project dialog, pick Host in the Environment section yourself.
- Active vs inactive: any project can be "active" (proxied + reachable) or inactive. Inactive projects keep their state, so flipping them on is a few seconds. Run as many active projects as you want.
Project cards (Configure tab)
Each registered project appears as a card in the Configure tab. Cards have a single-row header that's always visible and a tab-based body that expands on click.
Header
Reading left to right:
- Expand chevron + project name: click to expand/collapse the card.
- Status indicator: a single colored dot next to the project name aggregating the realtime state of every subsystem (Supabase services, Compose, scripts, AI sync, port conflicts, next.config warnings). Red = error, amber = warning, green = at least one service running, muted gray = idle, animated cyan spinner = transitioning. Hover for a tooltip that lists each subsystem's state.
- Tech badges: e.g.
TurboRepo,Supabase(shown when detected).
Supabase connection warning. When a project's app .env is missing the
Supabase connection vars, or they've gone stale relative to the live target
(e.g. after switching isolation, which republishes ports), the card shows a
supabase env: not connected / supabase env: out of date pill. Click it to
open Connect and push fresh values, or choose Ignore for this project.
- Env mode chip: read-only
HostorThinlabel (matching the project's isolation mode). To switch modes, open the Supabase tab and use the Environment section at the top. - Issues counter: red for errors, amber for warnings. Click to open the issues popover (see below). Hidden when there are no issues.
- Warnings chip: all project-level warnings (isolation drift, missing env vars, config issues, etc.) are consolidated into a single amber chip next to the enable toggle. Click it to see each warning item-by-item; it shows a spinner while Supbuddy re-checks the project.
- Enable toggle (right edge): turn the project's proxy on/off without deleting it.
- ⋯ actions menu (right edge): every project-level action: Edit project, Rescan, Re-check configs (re-runs the connection/env drift check for this project), Select folder, Export bundle, and Delete project.
Readiness banner
Between the header and the tabs, a card shows one bordered row per readiness finding — dependencies not installed or out of date, Supabase connection vars missing or stale, keys the project's .env.example declares that nothing sets. Each row carries the finding's detail, its evidence (project-relative paths and key names, never values), and, where Supbuddy can fix it, a button whose words come from the finding itself: Run pnpm install in a pnpm repo, Run yarn install in a yarn one. While an install runs, the banner shows a live tail of its output and a Cancel button, and the fix buttons are disabled — including when that install was started from the CLI or by an agent. If the install exits non-zero, the banner says so and keeps the output: a red row naming the exit code, the last lines of the run still under it, and a Dismiss button — it stays until you dismiss it or start another install. A cancelled install is not reported as a failure. A ready project shows no banner. See Project readiness.
Issues popover
Clicking the issues counter opens a popover listing all current errors and warnings — including readiness findings. Each issue shows a severity icon, title, optional detail, and a → open {tab} link. Clicking the link jumps to the relevant tab and closes the popover.
Body tabs (when expanded)
The body renders a flat tab strip with 6 conditional tabs. Below ~480 px, the strip collapses to a dropdown selector. (Project-level actions, like edit, rescan, re-check configs, select folder, export, and delete, are in the header's ⋯ menu, not a tab.)
Apps (default tab)
Per-app rows are domain-first: domain → :port (with hover-revealed copy/open URL buttons), then app name + tech badge, then a flex spacer pushes hover-revealed edit / delete / access (LAN / Tailscale state) actions and the per-mapping toggle to the right edge. A Map CTA appears on hover for unmapped apps. Manual mappings scoped to this project (not auto-generated) are listed below under their own subheader.
Supabase (shown when Supabase is detected)
Environment section (top): host/thin switcher. A legacy project still on the old Isolated (VM) mode shows the migration wizard here instead (see Migrating a legacy Isolated (VM) project to Thin).
Action bar: Start, Stop, Restart buttons; a first-class Connect button (cyan, opens the connection panel for .env generation / merge); and a More menu with Config editor and Details.
The connection panel: which button actually connects your app. The panel generates the project's Supabase/app-URL variables using the app's framework prefix and targets the file that framework loads (see Framework env vars: prefix and file). The Prefix dropdown opens on the prefix that table gives the app being written to — NUXT_PUBLIC_ for a Nuxt app, PUBLIC_ for Astro and SvelteKit, none for Remix or an app whose framework was not detected — and it follows the Write to app when you change it. Override it if you keep a different spelling; the dropdown offers every prefix Supbuddy knows, EXPO_PUBLIC_ included. Two buttons write, and they are not interchangeable:
- Set env vars (then Apply & next, one file at a time, with a per-key diff) merges the values into the env file your app actually reads —
.env.localfor Next/Vite/SvelteKit,.envfor Astro/Nuxt/Remix — backing the original up first and preserving your comments. This is the one that connects the app and clears thesupabase env:warning. - Write .env.supbuddy writes a reference file,
<app>/.env.supbuddy. No framework auto-loads it, so on its own it changes nothing your app can see and the drift warning stays. It is for copying values out of, or forsource-ing by hand.
Config editor: secret extraction. When you save a supabase/config.toml that contains a secret-bearing value inline (e.g. an SMTP password under [auth.email.smtp], an OAuth secret, or any *_key/auth_token), Supbuddy prompts before writing: it lists the detected secrets and lets you pick which gitignored env file to move them to (defaulting to the project-root .env.local). The value is written there and replaced in config.toml with an env(SUPABASE_…) reference, so secrets never land in git. Supbuddy injects those SUPABASE_-prefixed values back into the supabase start environment so the references resolve. (Saving a config with no inline secrets writes directly, with no prompt.)
Service rows (read-only): status dot, service name, URL. No inline actions; lifecycle is driven by the action bar.
Compose (shown when Compose services are detected)
Action bar: Start, Stop, Restart. Service rows are read-only (status dot, name, URL). Add-on services declared in supbuddy.addons.yml (see Add-on Compose services) appear here alongside the base stack and in get_compose_status over MCP.
Other (shown when non-Supabase, non-Compose services are detected)
Read-only service rows: status dot, name, URL.
Scripts (shown when scripts are detected)
Bookmarked scripts appear in a Quick Access group at the top; remaining scripts appear under Other Scripts. Per-script row: status dot, name, uptime, bookmark star, Start/Stop/Restart buttons. A search input appears when there are more than 5 scripts.
Stop kills the whole tree, not just the shell. A script runs as <manager> run <name> under a shell, so the dev server you care about is that shell's child — Supbuddy now signals the process group (SIGTERM, then SIGKILL five seconds later), so next dev/vite and anything they started go down with it. Earlier builds signalled only the tracked shell: the row went back to stopped while the server kept running and kept holding its port, and the next Start failed with EADDRINUSE against your own leftover process, which you then had to find with lsof -i :3000 and kill by hand. The same applies to Stop from supbuddy and over MCP — all three drive one supervisor.
AI Tools
Wraps the project-context-sync panel: sync mode selector (Auto / Manual / Off), detected targets list with per-target scope (global / local), advanced options, and recent activity. See Per-project AI context sync for what global vs. local means.
Project-level actions (Edit, Rescan, Re-check configs, Select folder, Export bundle, Delete) are no longer a tab. They live in the header's ⋯ actions menu.
Multiple Supabase projects (the main use case)
The reason Supbuddy exists. Stock Supabase CLI binds to fixed ports (54321 Kong, 54322 Postgres, 54323 Studio, 54324 mail). Two projects on the same machine collide; you must supabase stop one before supabase start-ing the other.
Two ways to break that constraint, picked per project in the Supabase tab → Environment section:
Thin (lightweight, recommended)
Switch a project to Thin. Supbuddy assigns it a free port block (in the 55000+ range), writes those ports plus a unique Compose project_id into its supabase/config.toml, and runs supabase start on your normal host Docker, with no nested containers and nothing to pull. Several projects boot side by side this way; each is reached by name (api.acme.test, studio.acme.test, mail.acme.test). Switch back to Host and Supbuddy restores the original config.toml and stops just that project's stack. A Thin project keeps that block for as long as it is Thin: anything that creates or replaces its supabase/config.toml afterwards gets the block re-written into the new file, because the content that lands there declares the stock 54321–54324 ports that belong to whichever project is on Host. That covers creating one — supabase init from the app or init_supabase over MCP on a project that had no Supabase yet, or repointing it with set_supabase_config_path — and replacing one: saving the visual config editor, supbuddy supabase config apply / write_supabase_config, restoring a config backup (supbuddy supabase restore, restore_supabase_backup) and restoring the config.hosted.toml snapshot. A backup or snapshot usually predates the switch to Thin, so restoring one verbatim would put the project back on the stock ports; the ports and project_id are re-imposed on top, and everything else in the file you restored is kept.
This is the lightest, fastest option and the right default for most setups — which is why newly registered projects default to Thin. One caveat: if your config.toml omits a port key (e.g. the mail catcher's smtp_port), Supbuddy can't relocate a port that isn't declared, so that one service falls back to its stock port. That is fine for a single project, but spell those keys out if two Thin projects need the same service. Where a whole section is missing and the rest of your ports sit outside the stock 54320–54329 block, Supbuddy reports that service's port as unknown rather than substituting the stock one — on a multi-project machine the stock port is another project's service, and a mapping built from it would open the wrong stack.
Supported config.toml layout
Supbuddy reads [api], [db] (port + shadow_port), [db.pooler], [studio], [analytics], and the mail catcher. Supabase CLI 2.x renamed the mail section [inbucket] to [local_smtp]; Supbuddy reads whichever one your file declares ([local_smtp] wins if both are somehow present) and falls back to the stock 54324/54325/54326 only when it declares neither. Thin's port rewrite targets the section you already have — it never adds the other spelling, because the CLI would ignore it while the diff made the port look moved.
Dev servers on Thin: every project keeps its own :3000
A Thin project also gets its own loopback IP (127.0.0.2, 127.0.0.3, …, persisted per project). That loopback covers app dev servers only — Supabase is separated by the port block above, not by the IP, so every project still needs its own Supabase port range in config.toml. Its app dev servers bind that IP instead of 127.0.0.1, so canonical ports never collide across projects — five Next.js apps in five projects can all run on :3000 at once, and Supbuddy's proxy routes each web.<project>.test to its project's IP.
Start dev servers through the launcher:
supbuddy run -- next dev # binds -H <project loopback IP>, stays on :3000
supbuddy run -- vite # injects --host <ip> --strictPort
supbuddy run --print -- next dev # show what would run, without running it
supbuddy run reads the project's IP from the nearest .supbuddy/meta.json (loopbackIp, written when Thin is enabled), ensures the loopback alias exists, injects the right bind flag for the detected framework, and execs your command. It prints one concise line with the project's Caddy-proxied URL (e.g. [supbuddy] → https://web.<project>.test) — the address you should actually open. For Next and Vite it also hides the dev server's own - Local:/- Network: banner (which only echoes the raw loopback IP 127.0.0.N:<port>, bypassing Supbuddy's HTTPS proxy): those two lines are filtered out of the piped output, every other line passes through untouched, and colours are preserved via FORCE_COLOR (stdin stays interactive). Other frameworks pass through with no filtering. When a project has several app mappings, it matches the one whose port equals the dev server's port (from --port/-p or the framework default), else lists them all. Make it the project's dev script ("dev": "supbuddy run -- next dev") so nobody — humans or agents — has to remember it. Never move an app to a nonstandard port because 127.0.0.1:3000 is busy; that port belongs to another project's IP space.
When to stay on Host
Keep a project on Host only when its Supabase stack runs on the host independently of Supbuddy — you run supabase start yourself on the stock ports and don't want Supbuddy rewriting config.toml. MCP registration (register_project) detects a stack like that (running containers for the project's config.toml project_id) and keeps the project on Host automatically; in the app's Add-project dialog, pick Host in the Environment section for such projects. Stop the stack (supabase stop) and switch to Thin whenever you're ready.
Running them all at once
Register as many projects as you want, and all of them can be "active" (proxied) at the same time. There's no limit. A Thin project's stack restarts in seconds; a Host project needs the standard supabase start cycle.
Migrating a legacy Isolated (VM) project to Thin
If you created a project in an older version of Supbuddy that used the now-retired Isolated (VM) mode, Supbuddy detects it on launch and offers a one-way, guided migration to Thin. The migration wizard appears in the Supabase tab's Environment section for any project still flagged as VM.
The migration is data-safe: Supbuddy dumps your Postgres data, starts a fresh Thin stack, restores the dump into it, and row-count-verifies the restore before tearing down the old VM container. No data loss. After migrating, the VM is gone and there's no way to switch back (but your data is intact in the Thin stack).
Over MCP, three tools handle the migration bridge:
list_pending_vm_migrations(read): lists all projects still on the legacy VM mode awaiting migration.migrate_vm_to_thin({ project_id }) (write): starts the guided data-safe migration (dump, restore, verify).finish_vm_migration({ project_id }) (write): tears down the old VM container after migration is verified. Returns an error if called before verification passes.
Custom domains & TLDs
Every mapping resolves through Supbuddy's built-in DNS server on port 5353. By default the TLD is .test (an IETF-reserved TLD safe for local use). You can change the default in Settings → General → Default TLD to local, dev, or anything else; existing mappings are migrated to the new TLD on save.
For host resolution, Supbuddy does not use /etc/hosts for wildcards; it runs a DNS resolver. macOS's default resolver only queries port 53; Supbuddy installs a per-project resolver file under /etc/resolver/<project-domain> (e.g. /etc/resolver/myapp.local) pointing at 127.0.0.1:5353. macOS picks the longest-suffix-matching file, so per-project entries route reliably without colliding with reserved namespaces like .local (which Bonjour/mDNS owns). You'll be prompted for sudo the first time this changes.
Resolver files exist only for domains the proxy actually serves — the same set that gets a Caddy site block: enabled mappings that are either standalone or under an enabled project. Disable or delete a project and its resolver file is removed with its routes (one sudo prompt, and only when something really changed), so its domains go back to failing as "server not found" instead of resolving into a TLS handshake error from a proxy that has nothing to serve. Enabling it again writes the file back; so does restarting the proxy.
When macOS won't load /etc/resolver: the /etc/hosts fallback
On a small number of Macs the /etc/resolver mechanism is simply inert. The files are present and correct, Supbuddy's DNS server answers every managed name on 127.0.0.1:5353, and scutil --dns still lists only the system's own resolvers — zero of Supbuddy's. Measured on macOS 26 with a responder that answers every query: a custom TLD failed, a custom TLD with a domain directive failed, and the real IANA TLD .dev failed too. /etc/hosts worked. This is not a TLD problem and renaming your domains will not fix it.
Supbuddy detects that state and works around it by maintaining a managed block in /etc/hosts.
- How it engages. After the proxy starts and the resolver files have been written and reloaded, Supbuddy resolves a name that only the resolver path can answer. If the files are in sync and that name still doesn't resolve — confirmed, not on a single miss — it writes the block. Since 3.7.8 that probe runs per suffix rather than sampling one domain, and it allows a longer budget on
.localsuffixes, where macOS makes a negative answer wait out the multicast window (measured at ~5 s) and the old 2.5 s budget turned a real "no" into "don't know". Nothing branches on your macOS version; it is the observed condition, so it is also correct for a machine locked down by a configuration profile or MDM. - What it costs. The condition can only be seen after the privileged setup has run, so the very first time it is detected you get one extra password prompt. Supbuddy then remembers the machine fact, and every later proxy start folds the hosts write into the same prompt as the resolver write. Steady state: one prompt, exactly as before. A declined prompt is not retried for 10 minutes, so it can never become a password loop.
- What it covers — and what it doesn't.
/etc/hostshas no wildcards. The block carries the exact names Supbuddy knows about: every enabled mapping, plus every enabled project's base domain, each pointing at127.0.0.1and::1(the same answers the DNS server gives). A brand-new subdomain that has no mapping will not resolve until you add one — the one behaviour difference you will notice. Everything else, including HTTPS and per-project TLDs, is unchanged. - How to see it. The block is delimited by
# Supbuddy DNS fallback - Start/# Supbuddy DNS fallback - End— rungrep -A20 'Supbuddy DNS fallback' /etc/hosts.get_healthreports it asdns.resolver.hosts_fallback: true, and the "domain doesn't resolve" banner says so in words. Your original file is copied once to/etc/hosts.supbuddy-backupbefore the first edit. - How to get out of it. It retires itself: as soon as a proxy start finds the resolver path answering again, Supbuddy removes the block and forgets the machine fact. Stopping the proxy also removes it (the same teardown that removes the resolver files), and
supbuddy reset --deepremoves it for good. You can also delete the block by hand — Supbuddy rewrites it on the next start only if the fault is still there.
Note that this block is not the old # Supbuddy - Start block from the pre-DNS-server era. That one is legacy, is deleted on every launch, and has nothing to do with this.
Per-project TLD
By default every project's domain uses the global TLD (Settings → Default TLD, e.g. .test). A single project can opt into its own TLD — set the suffix in the project dialog, pass tld to the register_project / update_project MCP tools, or use the CLI: supbuddy project add <path> --tld=portal when registering, or supbuddy project set <project> --tld=portal on an existing one (--tld= with an empty value clears the override). That project's base domain and all its subdomains then live on the override TLD (e.g. cueplusplus.portal, web.cueplusplus.portal) while every other project stays on the global default. The override is durable across restarts and is unaffected when you change the global TLD. Prefer .test or a vanity label like .portal; avoid .local (it collides with macOS mDNS/Bonjour).
LAN sharing
When LAN sharing is enabled (Settings → Network), Supbuddy binds Caddy to 0.0.0.0 instead of 127.0.0.1 and runs an mDNS responder so other machines on your local network can reach your dev servers via <hostname>.local. Useful for testing on your phone or another laptop without setting up Tailscale.
.local TLD + LAN sharing: macOS reserves the .local namespace for Bonjour/mDNS (RFC 6762), and macOS's TCP stack short-circuits self-connections to your own LAN IP via the loopback path without consulting pf, so the obvious "redirect lo0 → my LAN IP" trick can't fix it. Supbuddy's mDNS responder works around this by ignoring queries that originate from this machine, letting the OS resolver fall through to /etc/resolver/<project-domain> (which routes to 127.0.0.1 where Caddy listens). Other LAN devices still get answered with the LAN IP and reach you normally. The net result: .local works correctly both on this machine and on other LAN devices, with no manual configuration. If you previously worked around this by switching to .test, you can switch back.
If studio.<project>.local (or similar) doesn't load: open the Configure tab. A red banner will tell you whether it's a DNS, port-forwarding, or mDNS-race issue, with the specific recovery action.
Tailscale
If you have Tailscale installed and a Tailscale API key configured in Settings, Supbuddy can push split-DNS routes to your tailnet so any device on your tailnet resolves your Supbuddy domains. Optional, off by default.
Monorepo support
Supbuddy auto-detects these monorepo layouts when scanning a project root:
- Turborepo (presence of
turbo.json) - pnpm workspaces (
pnpm-workspace.yaml) - npm/yarn workspaces (
workspacesfield in rootpackage.json) - Common folder layouts:
apps/*,packages/*,services/*,sites/*
Each detected app gets its own subdomain. Supabase is searched for in the project root and these subdirectories: apps/*, packages/*, services/*, sites/*, db/, db/*, database/, database/*, packages/backend, packages/db, packages/database.
Detected app frameworks
Supbuddy picks an app's framework from evidence, not table position. It collects every known framework dependency in the package's package.json and takes the one whose dev/start script actually runs it — a package that merely depends on next for types is not a Next app, and a package whose scripts run none of them is not listed as an app at all. When two candidates both match, a fixed order breaks the tie, and that order puts meta-frameworks before the bundlers they sit on: SvelteKit and Remix-on-Vite both list vite as a direct devDependency, so a "dev": "vite dev" script resolves to SvelteKit rather than handing the app VITE_ variables it never reads.
Port detection then takes the first of: an explicit -p/--port in the dev/start script, a PORT= prefix in that script, a per-framework config file (vite.config.* / svelte.config.* for Vite and SvelteKit, astro.config.*, nuxt.config.*), then — for a meta-framework whose dev server is Vite — vite.config.*, and finally the framework default below.
| Framework dependency | Detected as | Default port |
|---|---|---|
next | next | 3000 |
vite | vite | 5173 |
@sveltejs/kit | sveltekit | 5173 |
astro | astro | 4321 |
nuxt, nuxt3 | nuxt | 3000 |
@remix-run/dev, @remix-run/serve | remix | 5173 when vite is a direct dependency (a remix vite:dev app binds Vite's port), otherwise 3000 |
@angular/core | node | 4200 |
@nestjs/core | node | 3000 |
express, fastify, koa, hono, @hono/node-server, elysia, polka, tinyhttp | node | none (must be explicit in dev script) |
SvelteKit, Astro, Nuxt and Remix are detected as themselves. Earlier versions reported SvelteKit and Remix as vite and let Astro and Nuxt fall through to node; the label is what decides the env-var prefix and the env file in the next section, so those apps were being handed variables they could not read. SvelteKit and Remix-on-Vite still get Vite's port and Vite's server.allowedHosts audit, because those follow the dev server rather than the label.
Framework env vars: prefix and file
An app only sees an environment variable if it is spelled with that framework's public prefix and written into the file that framework loads. Supbuddy keeps one table for both, and it drives the Connect panel, the connection-drift warning, and apply_env:
| Detected as | Public prefix | Env file |
|---|---|---|
next | NEXT_PUBLIC_ | .env.local |
vite | VITE_ | .env.local |
sveltekit | PUBLIC_ | .env.local |
astro | PUBLIC_ | .env |
nuxt | NUXT_PUBLIC_ | .env |
remix | none | .env |
react, node, unknown, or undetected | none | .env.local |
Remix having no prefix is the right answer rather than a gap: Remix hands values to the client through its loaders (the window.ENV pattern), not through a build-time prefix.
This table is also what the Connect panel's Prefix dropdown starts from, so the panel and the drift warning cannot disagree about your app: picking a prefix by hand is an override, not the default.
The Env file column is what Supbuddy creates when the app has no env file yet. When one already exists it is preferred over creating another: the framework's own file wins if present, otherwise any env target already in that folder — and an existing .env.local counts as a target for every framework, so an Astro or Nuxt app that already keeps one is not moved to .env. Files that look like backups (.bak, dated snapshots) or non-local overrides (.env.production, .env.local.live) are never write targets.
Two values are deliberately never prefixed, whatever the framework: DATABASE_URL and the Supabase service-role key. Both are server-only and must not reach a client bundle.
Supbuddy also mirrors what your file already spells. If SUPABASE_URL, SUPABASE_ANON_KEY or SUPABASE_STUDIO_URL is present both bare and prefixed (including EXPO_PUBLIC_, which the scanner never emits on its own), both spellings are updated, so neither goes stale.
Server Actions allowedOrigins audit
For Next.js apps, Supbuddy reads your next.config.{ts,mts,js,mjs,cjs} and extracts the hosts in experimental.serverActions.allowedOrigins. If a mapped subdomain is missing from that list, the project's warnings chip flags next.config: N origins missing; Server Action POSTs through Supbuddy mappings would 403 otherwise. Open the Apps tab (the chip's "open apps" jump) where the affected app shows the warning with a Fix button.
The Fix button opens a dialog with a paste-ready snippet and an Apply… button: click it to see a unified diff of the change Supbuddy will make to your next.config, then Confirm & write to apply it. Supbuddy handles the four common config shapes (existing allowedOrigins array, existing serverActions block without it, existing experimental block without serverActions, or no experimental at all). The edit is strictly additive: existing array entries are kept verbatim, including spreads (...devHosts), identifiers and comments, and only the missing origins are appended.
If allowedOrigins (or serverActions, or experimental) is set to something other than a plain array/object literal — an identifier, a function call, a ternary, [...] as string[] — Supbuddy refuses to patch rather than guess, and the dialog says so along with the exact origins to add. This is deliberate: a wrong rewrite would produce a duplicate key (TypeScript TS1117) that breaks your build long after the fact, so the fallback is the copyable snippet. Use it and edit by hand.
After write, Supbuddy rescans the project so the warning disappears immediately. Restart your dev server for the change to take effect; Next.js does not hot-reload next.config. Over MCP the same audit is exposed as preview_next_origins / apply_next_origins; both return ok: false with an explanation in the refusal case, and apply_next_origins never writes a file it cannot verify.
Next.js cross-origin dev requests (allowedDevOrigins)
Supbuddy proxies your dev server but passes the browser's real Origin header through (it no longer rewrites Origin to the upstream address). That's required so Server Actions and other origin checks see the actual page origin — but it means Next.js 15.3+ and 16 dev servers, which validate cross-origin dev requests against allowedDevOrigins (defaulting to localhost), now treat a request arriving on a Supbuddy domain (or a Thin project's 127.0.0.N loopback IP) as cross-origin and can reject it. Add your Supbuddy domain to allowedDevOrigins in next.config:
// next.config.js
module.exports = {
allowedDevOrigins: ['web.myproject.test'],
}
Restart the dev server afterward; Next.js does not hot-reload next.config. This is separate from experimental.serverActions.allowedOrigins (the Server Actions CSRF list above) — 15.3+/16 may need both.
Vite allowedHosts audit
For Vite apps, Supbuddy reads your vite.config.{ts,mts,cts,js,mjs,cjs} and extracts server.allowedHosts. If a mapped host isn't covered, the warnings chip flags vite: N hosts blocked; Vite's dev server otherwise rejects proxied requests for unknown hosts with Blocked request. This host ("…") is not allowed. (403). A .your-project.local entry counts as covering every subdomain, so an existing wildcard suffix doesn't trigger a false warning.
Like the Next.js audit, the affected app's Fix button on the Apps tab opens a dialog with a paste-ready snippet and an Apply… button that previews a unified diff and writes server.allowedHosts into your vite.config (handling an existing allowedHosts array, an existing server block without it, or no server block at all; allowedHosts: true is left untouched). The edit is strictly additive — existing entries, spreads and comments are kept verbatim and only missing hosts are appended — and, exactly as with the Next.js audit, Supbuddy refuses to patch when allowedHosts or server is set to anything other than a plain array/object literal, pointing you at the snippet instead of risking a duplicate-key build break. After write, Supbuddy rescans so the warning clears. Restart your dev server for the change to take effect; Vite does not hot-reload vite.config.
Project readiness (can it run?)
Registering a project gets it a domain, a certificate and a scripts list. None of that makes it run. Two things stop it, and readiness is the one check that covers both:
- Dependencies aren't installed, or the lockfile has moved on since the last install. Before this check the way you found out was clicking Start on a dev script and reading its
exit 127several steps downstream of the cause. - Environment variables are missing or stale — Supbuddy's own Supabase/app-URL values aren't in the file that app's framework actually loads, or keys the project's own
.env.exampledeclares aren't set anywhere.
Every finding carries a severity (error / warning / info), a category (deps / env / toolchain), evidence, and — when Supbuddy can genuinely fix it — a fix descriptor: the name of a real tool plus the arguments to call it with. The project card's button, supbuddy ready --fix and an agent's MCP call all render that same descriptor, so the three cannot drift and all three run the right package manager for this project. A finding with no fix is advisory on purpose: either Supbuddy has no value to supply (a third-party secret — it will name STRIPE_SECRET_KEY, it will never invent one), or it refuses to guess (nothing identifies a package manager, or that manager isn't installed, in which case an install would only exit 127).
Evidence carries names and verdicts, never values. A readiness report reaches the MCP audit log, CLI stdout and the UI at once, so a finding names the key and the file — project-relative — and stops there. No secret, no home directory, no KEY=value.
Supbuddy never installs anything on its own. Readiness reports; a person or an agent decides.
Variables your code reads that nothing sets
Readiness also reads your source — process.env.KEY, process.env['KEY'], import.meta.env.KEY, Deno.env.get('KEY') — and names the keys that no env file sets and no example file declares. It looks project-wide, so a monorepo that keeps one .env at the root and loads it with dotenv-cli isn't reported wholesale.
This is the one check that infers rather than compares, so it is fenced in on every side:
- Severity is always
info. It can never make a project "not ready", and it is kept out of the card's issues counter — it appears in the readiness banner, toned down, and nowhere that counts problems. - There is no fix. Supbuddy has no value for
RESEND_API_KEYand will not invent one. - Every key carries the first
file:linethat reads it, so a false positive is dismissible on sight instead of being a claim about your repo you'd have to go audit. The matched line itself is never quoted: one line of source can carry a credential (process.env.DATABASE_URL ?? 'postgres://user:pw@host/db'). - The finding says in its own words that it is inferred and can be wrong. A key behind a flag you never enable, one your CI supplies, one inside a code sample — from the outside all three look exactly like a variable you forgot.
Where it doesn't look: anything your .gitignore excludes, plus node_modules, dist, build, .next, .nuxt, .svelte-kit, coverage and .git — and your tests and build configs (*.test.*, *.spec.*, __tests__/, test/, tests/, *.config.*), because a variable only a test or a bundler reads isn't something you have to set to run the app. Comments are not code: a key that only appears in a comment is not reported, so prose explaining process.env.SOMETHING doesn't become a finding about a variable that doesn't exist.
What it never reports: NODE_ENV, PORT, CI, HOME, PATH, TZ, npm_*, VERCEL_*, XDG_*, NEXT_RUNTIME, Vite's own MODE / DEV / PROD / SSR / BASE_URL, and the names your OS or terminal supplies — APPDATA, LOCALAPPDATA, FORCE_COLOR, NO_COLOR, COLORTERM, TERM, SHELL, EDITOR, SUDO_ASKPASS. The platform's variables, not yours. (GH_TOKEN is not on that list: a project that reads it may genuinely need you to provide it.)
A scan that hits its limits reports nothing at all. The walk is capped at 2000 files for the whole project and 1 MiB per file; exceeding either yields no referenced-variable findings rather than the subset it managed to read. A truncated list under that heading reads exactly like a complete one — you'd set the three keys it named and trust the silence about the rest.
Install roots: one per lockfile, not one per app
The unit of install is the directory that owns a lockfile. In a pnpm/yarn/npm workspace that is one directory — the repo root — and a single install covers every app and every shared package in it, so a monorepo with five apps has one install root ("", the project root). An app becomes its own install root only when it owns its own lockfile; then it gets a second root and its own install.
Supbuddy picks the package manager by strict precedence, first match wins:
| # | Signal | Result |
|---|---|---|
| 1 | packageManager field in that directory's package.json | its name (and version) |
| 2 | pnpm-lock.yaml | pnpm |
| 3 | bun.lockb or bun.lock | bun |
| 4 | yarn.lock | yarn |
| 5 | package-lock.json or npm-shrinkwrap.json | npm |
| 6 | none of the above | undetermined — never a silent npm |
Case 6 is a real answer, not a gap: running npm install in what is actually a pnpm repo drops a stray package-lock.json into your working tree, which then gets committed and mis-trains every detector that reads it afterwards. So a root with no signal gets no install command and no install button — the finding says so in words instead.
Whether an install has happened is read from the manager's own receipt, never from the presence of node_modules:
| Manager | Receipt |
|---|---|
| pnpm | node_modules/.modules.yaml |
| npm | node_modules/.package-lock.json |
| yarn (node-modules linker) | node_modules/.yarn-state.yml (Yarn 1 falls back to node_modules/.yarn-integrity) |
| yarn (PnP) | .yarn/install-state.gz |
| bun | node_modules/.bun-tag, else node_modules itself |
A receipt older than the lockfile is stale (a warning: the project runs, it just no longer matches the repo). No receipt is not installed. A receipt Supbuddy cannot read is unknown, which produces no finding at all — receipt filenames drift between manager versions, and a reinstall button in front of a user whose project is fine is worse than saying nothing. Yarn PnP projects have no node_modules and are correctly installed; they are read from .yarn/install-state.gz, so they are never reported as broken.
What packageManager on a project now means
Project.packageManager — the value supbuddy project ls prints and get_project / list_projects return — comes from that one detector now, which changes two things:
- It can read
bun. - It is absent for a project where nothing identifies a manager. Older builds always printed
npmthere, because the old detector's fallback was a guess rather than an observation. A project with no lockfile and nopackageManagerfield therefore now shows no package-manager chip at all instead of a confident wrong one.
Running a script uses that same answer. Starting or restarting a script — from the Scripts tab, from supbuddy, or over MCP — resolves the manager from the project's recorded value, falling back to what the project root shows. If nothing identifies one, all three refuse and spawn nothing, naming the cause ( no packageManager field, no lockfile ) and the two ways out: add the field, or install once so a lockfile exists, then rescan the project. Earlier builds quietly ran npm run <script> in that case, so one repo could be described as npm by the Scripts tab and as undetermined by supbuddy ready at the same time.
Pushing to the cloud uses it too. The dev command Supbuddy hands each app in a cloud box comes from that same answer — the app's own manager when it owns a lockfile, otherwise the workspace root's, which is what installs every member of a pnpm or yarn monorepo. An app for which nothing identifies a manager is reported as not running in the cloud, with the reason, rather than started under a guessed npm run dev: the wrong manager resolves a different dependency tree, and npm would drop a stray package-lock.json into a workspace that syncs straight back to your machine. The box's own dependency install reads the same lockfile names in the same order, so a repo locked by bun.lock (the only lockfile bun 1.2 and later writes) or by npm-shrinkwrap.json is installed like any other instead of being skipped and left to fail on its first app start.
Installing is a job, not a call
A cold install on a monorepo outlives every timeout on the path between you and the worker, so starting one returns immediately with a job ({ id, state: "running", argv, rootPath }) and the output arrives as a stream. Completion is a state transition you can watch or poll; cancellation is a first-class operation.
- At most one install per install root. A second request for a root that is already installing hands back the same job rather than starting a duplicate — two package managers writing one
node_modulesis a corrupted store, not a slow install. Different roots of one project are independent directories and run at the same time. - A cancelled install is reported as
cancelled, neversuccess, even if the child happened to exit 0: it may have been halfway through writing the store. Treat the dependencies as not installed and run it again. - Deleting a project stops the installs still running for it, so nothing keeps writing into a folder you just told Supbuddy to forget.
- And its dev servers. Deleting a project — from the app, from
supbuddy, or viadelete_projectover MCP — stops every script it had running, for the same reason plus one more: once the project is gone there is no row left to press Stop on, so a survivor holds its port against whatever you register next with nothing in the UI able to reach it. Deleting with keep data still stops them; that option preserves the project's data, not a process writing into it.
On the project card
An unready project shows a readiness banner above the card's tabs: one bordered row per finding, toned by severity, with the finding's detail and its evidence underneath. Where the finding has a fix, the row carries a button whose words come from the descriptor — a yarn project reads Run yarn install, a bun project Run bun install — and hovering an install button shows the literal command it will run. An env finding's button runs the same apply_env merge described under MCP tool surface: it writes into the file the framework loads, backing the original up first.
While an install runs, the banner shows a live tail of its output (the same ANSI rendering as the Scripts tab) and a Cancel button, and every fix button on that project is disabled — including when the install was started from the CLI or by an agent, since all three surfaces drive the same job. Readiness findings also feed the header's issues counter and its popover, with the same → open {tab} jump as every other issue.
A failed install is stated, not swallowed. An install that exits non-zero does not throw anywhere — the job simply reaches failed — so the banner would otherwise just re-render the same "dependencies are not installed" row with the output gone, which reads as a button that did nothing. Instead the banner shows Install failed (exit code N) and keeps the tail of that run underneath it, so the lines that explain the failure are still on screen when you need them. It persists until you Dismiss it or start another install, and it says only what happened and how it ended — Supbuddy does not guess at a cause. A run killed by a signal or one that never started has no exit code to name and carries the runner's own error text instead. An install you cancelled is never shown as a failure.
A ready project shows no banner. The card's status dot already carries the positive case.
Which Node runs your project's commands
Supbuddy runs your install and your dev scripts through your package manager, and until 3.7.8 it did not choose which Node that manager ran on — it built a PATH and let the shell resolve whatever came first. On a packaged app that inherits launchd's PATH rather than your shell's, /usr/local/bin sat ahead of every version-manager directory, so a stray /usr/local/bin/node could win over every version you actually use. One reported case: a repo pinning 22.23.2 in .nvmrc, with 22.23.2, 24.19.0 and 26.7.0 all installed and all satisfying its engines range, had pnpm install refused by ERR_PNPM_UNSUPPORTED_ENGINE because Supbuddy ran it on an unrelated 22.15.1.
Supbuddy now reads the project's own request and runs the command with it. The request is taken from, in order, .nvmrc, then .node-version, then package.json's engines.node — searched from the directory the command runs in upward to the project root, so a monorepo's root pin covers its packages while a package's own pin still overrides it. Installed versions are found from nvm, fnm, asdf, mise, volta and n; when more than one satisfies the request, the highest wins. Full engines ranges are honoured, including >=22 <25 and ^22.22.2 || ^24.15.0 || >=26.0.0; an unconstrained range — *, x, >=0 — counts as no request at all.
A request is a floor to clear, not an instruction to upgrade. If the Node that would have run anyway already satisfies the range, Supbuddy changes nothing. This matters because engines.node: ">=18" is boilerplate in a large share of repos: read as a request and answered with the highest installed version, it would quietly move such a project off the version you selected with nvm use — and say nothing, because nothing failed. The pin is honoured when it is not already satisfied, which is the case that was actually broken. This applies to every command Supbuddy spawns for a project — installs, supbuddy run, and dev scripts started from the app — not just installs.
Aliases are not guessed. lts/iron, lts/*, node and stable cannot be resolved without asking the network which release is current, so Supbuddy reports them and leaves the PATH alone rather than picking something plausible. Pin a concrete version if you want it selected.
It never installs a runtime for you. When nothing installed satisfies the request, the project card raises a toolchain finding that names both versions — "This project asks for Node 22.23.2 (.nvmrc); Supbuddy would run v22.15.1" — with no fix button, because installing Node is your version manager's job, not Supbuddy's. Note the consequence: a project whose pinned Node isn't installed now reports as not ready, where previously it reported ready and then failed at install time. That is the intended trade — pnpm refuses outright in that state — but it is a new way for a card to go red.
A project that pins nothing is unaffected, byte for byte: the PATH it gets is exactly the one it got before. One related fix does apply everywhere, pinned or not — if a version manager already has a version active in the PATH Supbuddy inherited, that version now keeps its place instead of being demoted below the inactive ones.
Changing .nvmrc takes effect on the next command with no restart. Installing a new Node version is also picked up on the next command; the PATH is rebuilt per spawn rather than cached.
One line in doctor
supbuddy doctor (and the app's System health panel) carries a single aggregate warning — projects-not-ready — when registered projects wouldn't run: "3 project(s) are not ready to run", with the project names as evidence and nothing else. That is deliberate. Per-project detail belongs on the card and in supbuddy ready, which can name the install root, the key and the file; a machine with twenty projects must still get a report about the machine. The line is advisory: doctor stages no install and offers no repair for it — it exists to send you to the surface that can explain it. A project whose folder has been deleted is skipped rather than reported forever on the strength of a leftover record, and a project Supbuddy could not read is listed as (could not be scanned) rather than quietly counted as healthy.
MCP setup (AI agents)
Supbuddy ships a built-in MCP server on http://127.0.0.1:9877/mcp with static Bearer-token auth. Five clients have one-click install; any other MCP-compatible tool can be configured manually with the same URL + token.
Open Settings → MCP → Add client, pick the client kind, and Supbuddy generates a token, edits the client's config file, and backs up the original (<file>.supbuddy-backup next to it). If the install can't complete it surfaces an error toast rather than stalling. The same client-management surface (Settings → MCP → Clients: install, edit scopes, set-primary, rotate token, revoke) drives each client from the app.
Auto-install paths
| Client | Config file | Transport |
|---|---|---|
| Claude Code | ~/.claude.json (user) or <project>/.mcp.json (project) | HTTP |
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json | stdio shim via npx -y @supbuddy/mcp@latest |
| Cursor | ~/.cursor/mcp.json (user) or <project>/.cursor/mcp.json (project) | HTTP |
| Codex CLI | ~/.codex/config.toml (adds an [mcp_servers.supbuddy] block) | HTTP |
| Windsurf | ~/.codeium/windsurf/mcp_config.json | HTTP |
MCP tool surface
The MCP server has full read and write access:
-
Read tools (
list_mappings,list_projects,get_health,get_compose_status,list_pending_vm_migrations, etc.), with env values and request bodies included. -
get_client_capabilitiesandrequest_scope_elevation(scope discovery + user-approved grant). -
read_env_file,tail_request_logs,watch_audit_log. -
Write tools:
create_mapping,delete_mapping(soft-delete),register_project,update_project,set_supabase_config_path,start_proxy,start_supabase,stop_supabase,restart_supabase,switch_isolation,migrate_vm_to_thin,finish_vm_migration,start_compose,stop_compose,restart_compose,scaffold_addons,seed_addons,write_env_file,copy_env_var,write_supabase_config. -
Scripts tools (
list_scripts,start_script,stop_script,restart_script,bookmark_script,tail_script_logs); see Scripts MCP tools below. -
Project readiness (see Project readiness above):
get_project_readiness({ project }, scoperead) reports whether each install root's dependencies are installed and whether the app's env vars are present and current. It spawns nothing and writes nothing, so it is safe to poll. Every actionable finding carries afix—{ tool, args, label, command }naming a real tool call — which you pass straight to that tool:install_dependenciesfor a missing or stale install,apply_envfor connection-env drift. Do not compose your own remedy and do not run a package-manager command directly; the descriptor already carries the right manager for that project.install_dependencies({ project, root? }, scopeprojects) is destructive, so under the default client it returns a plan naming the exact argv and cwd; applying it starts a job and returns immediately with anInstallJob, never a finished install. Follow it withtail_install_log({ job_id }, scoperead), which replays what the job already printed and then streams to completion, or stop it withcancel_install({ job_id }, scopeprojects). A secondinstall_dependenciesfor a root that is already installing returns the same job rather than starting a duplicate. It refuses, having spawned nothing, when no lockfile orpackageManagerfield identifies a manager and when that manager's binary isn't on Supbuddy's PATH. -
Extended Supabase tools:
init_supabase,validate_supabase_config,list_supabase_backups,restore_supabase_backup,cancel_supabase_start,restart_supabase_container,get_supabase_analytics,set_supabase_analytics. -
Bundle (export/import a project's full config):
export_bundle,import_bundle,validate_bundle. -
Supbuddy Cloud (opt-in, per-project):
cloud_sign_in,push_to_cloud,get_cloud_status,preview_cloud_env,cloud_teardown, plus live sync (cloud_sync_start,cloud_sync_status,cloud_sync_stop) — push a project (with its Supabase schema + data) to a hosted cloud stack and control it. Thecloudlink ({ projectId, stackId, pushedAt, url }) also appears onget_project/list_projects, so any client sees which projects are in the cloud.get_cloud_statusalso returns aboxsummary — what the stack's box last reported doing, as a phase plus a per-unit state list, withreport_atso the caller can age it. It is deliberately structural: the box's free-text detail is NOT included, because that text is written by whatever runs inside the box and this value reaches an agent's context. Absent (null) when the stack has never reported or runs an image with no reporter.preview_cloud_envanswers what a project's env would MEAN in a cloud box, before anything is pushed. Values are classified, never uniformly substituted — a blanket rewrite silently repoints a project at a different backend, and a blanket copy points a cloud box at a database on somebody's laptop. Each variable comes back as local (127.0.0.1, a.localhost, a LAN address — meaningless inside a box), remote (correct as-is in both places), secret, or plain, each with a reason in plain words, plusneeds_attention— the count of local ones, the only number that implies an action. It is read-only and changes nothing. Secret values are never returned — not masked, not truncated, omitted: a masked secret is still a decision to send it somewhere, and a preview has no use for the value. -
Connection / env-target workflow:
preview_connection,get_env_targets,diff_env,apply_env,write_connection,test_connection,dismiss_connection_drift.apply_envis the one that connects an app: it merges into the file the framework loads (.env.localfor Next/Vite/SvelteKit,.envfor Astro/Nuxt/Remix — see Framework env vars), backs it up first, and clears the connection-drift warning.write_connectionwrites a reference<app>/.env.supbuddythat no framework auto-loads; it never satisfies the app and never clears that warning. Both compute the same values with the app's framework prefix. -
Host & network tools: bundled-runtime trust (
get_trust_status,install_trust,remove_trust,detect_trust_tools,test_trust), Tailscale (get_tailscale_status,set_tailscale_key,remove_tailscale_key,test_tailscale), DNS (get_dns_status), CA (uninstall_ca), and port-forwarding (get_port_forwarding_status,set_port_forwarding,reload_port_forwarding). Two port-forwarding fields mean different things and are reported separately:enabledis what you asked for,enforcedis whether the443 → 8443redirect is actually live — probed, not remembered.enforcedisnullwhenever the probe would be meaningless — the proxy is stopped, or nothing is listening on the HTTPS port — andnullmeans unknown, never a fault: it raises no finding, no degraded flag and no password prompt.get_proxy_statusandget_healthboth carry the same distinction asportForwardingEnabledandportForwardingEnforced, and reportnetworkingDegraded: truewhen the two disagree, because a redirect that is switched on and not working is an outage rather than a setting. The live probe is decisive in both directions: it overrides a stored flag that claims health, and it also clears one left behind by an abandoned repair once the redirect is confirmed working.reload_port_forwardingre-applies the rules with a sudo prompt and returnsokonly once a fresh probe confirms 443 answers — a successfulpfctland a working redirect are not the same claim.set_port_forwardingdeliberately returns nookfield at all: the elevation runs on the host and resolves after the tool has already replied, so it reportsrequestedplusconfirmed: falseand points you atget_port_forwarding_status. It can still fail afterwards — a declined prompt, a timeout, or a ruleset that fails validation — and a success token there would be a guess, not an observation. -
tail_service_logs: streams a Compose/add-on service's container logs over SSE (liketail_request_logsbut for container stdout/stderr). -
watch_supabase: streams a project's live Supabase start/stop/restart progress over SSE: operation status, image-pull/service snapshots, and (for VM projects) raw log lines. Backssupbuddy supabase start --follow. -
System doctor:
doctor(scoperead) runs the read-only health & drift scan and returns a report of findings (each with acheckId, severity, evidence, and whether it'sfixable) — it mutates nothing.doctor_fix({ check_ids: [...] }) applies the opt-in repairs for those checks; it's system-scoped and confirm-gated (a modal, exactly likeuninstall_ca), so a read-scoped client can't trigger a fix and an agent can't silently run a destructive repair. Backssupbuddy doctor/doctor --fix(see System doctor). -
System reset:
system_wipe({ tier: "soft" | "deep" }, scopesystem) runs the tiered reset described under System reset. It is gated twice: it always returns a plan first — even forauto_applyclients — whoseside_effectsare the literal manifest the wipe will execute, and the subsequentapplystill blocks on a user confirmation modal.tier: "full"is rejected: it deletes the credentials the caller is authenticating with, and its final steps (uninstalling the service, removing the app-data directory) can't run inside the daemon — runsupbuddy reset --tier=fullin a terminal instead. -
Multiple MCP clients can connect simultaneously. The same MCP-HTTP surface backs the headless CLI (see Command-line interface below).
Scopes: discovery & self-service elevation
Each MCP client holds a set of scopes (read, log_tail, mappings, projects, services, config, system, apply) chosen when it's added. A tool call that needs a scope the client lacks fails with scope_denied, whose payload now carries a user_message and details.remediation pointing at the fix.
get_client_capabilities({ tool? }) returns the calling client'sgranted_scopesandavailable_scopes. Pass atoolname to get{ required_scope, required_feature, can_call, reason? }so an agent can pre-flight a call instead of probing by hittingscope_denied.request_scope_elevation({ scopes: [...] }) asks the user to grant the named scopes. Supbuddy shows a blocking approval dialog; on approval the scopes are added to the client. Already-granted scopes short-circuit without a prompt.
You can also review and edit any client's scopes from the GUI: Settings → MCP → Clients lists each client's granted scopes inline and exposes a Scopes button that opens the same scope editor used when adding a client.
Registering a project via MCP
register_project takes a root_path (required), an optional label, auto_scan (default true), and an optional isolation ('thin' or 'host'). It registers the project the same way the GUI's "Add project" flow does:
- Derives a base domain as
<slug>.<defaultTld>from the label (or the folder name), e.g.staffhub.test. - Records both the project
pathandrootPathso the project is visible to the proxy, scans, and file tools alike. - Scans the folder (unless
auto_scan: false) for apps, services, scripts, and package manager. - Creates per-app subdomain mappings from the discovered apps (e.g.
site.staffhub.test → :3400), derives the host service subdomains (api.,studio., …), and reloads Caddy. - Defaults to
thinisolation: the project gets its own loopback IP so its dev servers keep canonical ports (:3000) with no cross-project collisions — run them withsupbuddy run -- <dev command>.
A project lands on host in exactly three cases:
- You passed
isolation: 'host', or Settings → Default isolation is host. (isolation: 'thin'forces thin and skips the detection below.) - The project's Supabase stack is already running on the host outside Supbuddy — switching would rewrite its
config.tomlports and orphan that stack, so registration keeps it on host. - Thin was attempted and failed — most often because creating the project's
127.0.0.Nloopback alias needs sudo and the prompt was dismissed. The project is left on host withisolationErrorset.
Case 3 is a fallback, not a deliberate outcome: retry it with switch_isolation { target_mode: 'thin' } and then apply the plan that stages (see Plan / apply for destructive tools).
The response includes an isolation_note explaining which mode was chosen and why — agents should read it instead of assuming.
Switching isolation over MCP
switch_isolation ( { project_id, target_mode: 'host' | 'thin', auto_start? } ) moves an existing project between host and thin mode. It is a destructive tool, so unless the client has auto-apply it stages a plan rather than switching — call apply with the plan_id to execute it (see Plan / apply for destructive tools). Execution then runs in the background and returns { started: true }; poll get_project (isolation, and loopbackIp for thin) for the current mode. To-thin writes the per-project port block and project_id into supabase/config.toml and (unless auto_start: false) starts Supabase; to-host restores the original config.toml and stops that project's stack.
A project can also be patched with update_project: its patch accepts name, enabled, domain, and isolation (it intentionally does not accept path/rootPath). Note that patching isolation only flips the flag; use switch_isolation to actually provision/tear down the port assignment.
Legacy VM migration over MCP
For projects still on the retired Isolated (VM) mode, three tools handle the one-way migration to Thin:
list_pending_vm_migrations(read): lists all projects still on the legacy VM mode, with their currentvmStateand migration readiness.migrate_vm_to_thin({ project_id }) (write): starts the guided data-safe migration. It dumps Postgres data from the VM, starts a fresh Thin stack, restores the dump, and row-count-verifies before signalling completion. Returns{ started: true }; pollget_project(migrationState) for progress.finish_vm_migration({ project_id }) (write): tears down the old VM container after verification passes. Errors if called before the verify step completes.
Repointing a project's Supabase config
set_supabase_config_path ( { project_id, supabase_path } ) switches which supabase/config.toml a project uses, for monorepos that carry more than one (e.g. a repo-root config and an app-level one). supabase_path is the project-relative directory containing the supabase/ folder ("." for the repo root, e.g. "apps/getnightowls"). It persists the path, re-derives supabaseProjectId from the new config, and re-scans services. For a Thin project it also re-writes that project's port block into the newly pointed config (reported under thin_supabase_block), since the file it points at declares the stock ports. The previous stack's Docker volume is left intact (not deleted), so the switch is reversible; the response reports it under orphaned_previous_stack.
Moving a secret between env files
copy_env_var ( { source_path, source_key, target_path, target_key? } ) relocates a single variable from one env file to another (e.g. a value put in an app's .env.local that the stack actually injects from the repo-root .env.local). The value is read and written entirely inside the worker (it never crosses the MCP boundary and never appears in the audit log), so an agent can move a secret without it being printed. target_key defaults to source_key.
Plan / apply for destructive tools
Tools that delete or mutate state (delete_mapping, delete_project, switch_isolation, write_env_file, etc.) return a plan with a preview instead of a result. The MCP client (or you, in the Activity panel) explicitly calls apply with the plan_id to execute; cancel_plan discards it. Plans expire after 5 minutes if not applied. Soft-deletes go to the Trash and are recoverable for 7 days. A client with auto-apply skips staging and executes directly — except system_wipe, which always stages.
A staged plan carries two fields an agent should act on:
can_apply: true— the plan is applyable. It is on every pending plan, because a plan only exists once scope, argument validation and rate limiting have all passed. In particular it outranksrequired_feature, which is declarative metadata that nothing enforces — never read that field as a denial.__apply_via— the literal next call:{ tool: 'apply', args: { plan_id } }. The mirror of__reversible_viaon completed operations.
The MCP server states the same contract in its initialize instructions, so any client sees it at connect time.
Add-on Compose services
A project can declare extra Docker Compose services that Supbuddy discovers, merges, runs, health-checks, and tails alongside the managed stack: a Redis cache, a worker queue, a search engine, etc. Add-on services run on the host's shared Docker daemon in both host and thin isolation, with no extra setup needed.
Declaration files & merge precedence
Supbuddy looks for up to three Compose fragments in the project and merges them, later wins:
docker-compose.yml: your base Compose file.docker-compose.override.yml: your own override, honored if present (standard Compose convention).supbuddy.addons.yml: Supbuddy-owned add-on fragment.
All present fragments are passed explicitly, e.g. docker compose -f docker-compose.yml -f docker-compose.override.yml -f supbuddy.addons.yml --project-name <pinned> …. The project name is pinned so the same set of containers is addressed every time. Add-on services join the Compose project's default network automatically; no extra network setup is needed for them to reach (or be reached by) the rest of the stack.
supbuddy.addons.yml format
A valid Compose fragment (a standard services: map) plus an optional Supbuddy-only x-supbuddy: extension block. A plain docker compose up ignores x-supbuddy:, so the file stays usable without Supbuddy. Today x-supbuddy supports a one-shot seed step:
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
x-supbuddy:
seed:
service: redis
command: ["redis-cli", "ping"] # explicit argv, runs once after services are healthy
runOnce: true
The seed step runs once after the add-on services are up and healthy. It's idempotent, keyed by a signature of the seed spec, so it only re-runs if the spec changes (or you force it). It fires automatically on project start, and on demand via the seed_addons MCP tool.
MCP tools
scaffold_addons({ project_id }): scopeconfig. Creates a startersupbuddy.addons.ymlif the project doesn't have one. Never clobbers an existing file.seed_addons({ project_id, force? }): scopeservices. Runs the declaredx-supbuddy.seedstep. Idempotent unlessforce: true.tail_service_logs({ project_id, service }): scopelog_tail. Streams a Compose/add-on service's container logs over SSE (liketail_request_logs, but for container stdout/stderr).watch_supabase({ project_id }): scopelog_tail. Streams a project's live Supabase start/stop/restart progress over SSE:operation(status + message),progress(image-pull/service snapshots), andlog(raw lines, VM projects). The stream ends on a terminal status. Backssupbuddy supabase start --follow.
Scripts MCP tools
Scripts detected in a project (e.g. dev, build, test) are controllable over MCP:
list_scripts({ project_id }): scoperead. Returns all detected scripts with their current status and bookmark state.start_script({ project_id, script }): scopeservices. Starts the named script process. Refuses, having spawned nothing, when nopackageManagerfield and no lockfile identify a manager for the project — the same verdict readiness gives that repo.stop_script({ project_id, script }): scopeservices. Stops the named script process — the whole process group, so the dev server under the shell goes with it.restart_script({ project_id, script }): scopeservices. Stops then starts the named script process. Refuses on the same terms asstart_script.bookmark_script({ project_id, script, bookmarked }): scopeservices. Pins (bookmarked: true) or unpins a script in the Quick Access group.tail_script_logs({ project_id, script }): scopelog_tail. Streams the named script's stdout/stderr over SSE.
get_compose_status shape
get_compose_status ( { project_id } ) returns per-service status, not just whether Compose is installed:
{
"project_id": "…",
"compose_installed": true,
"running": true,
"services": [
{ "name": "redis", "status": "running", "health": "healthy", "ports": ["6379:6379"], "image": "redis:7-alpine", "container_id": "…", "source": "addons" }
],
"services_source": "store-snapshot (updated by docker events, not probed by this call)"
}
Each service's source is one of base | override | addons, telling you which fragment declared it.
The service statuses are a snapshot, kept current by Supbuddy's docker-events watcher rather than probed when you call — which is why services_source says so. Only compose_installed is checked on the call itself. get_supabase_status reports the same way, and answers the question its name asks: running plus the project's Supabase services, alongside the machine-level cli_installed and docker_running.
Per-project AI context sync
Each project has a Context sync: AI tools panel, accessible via the AI Tools tab in the project card, that writes a project-scoped briefing to disk so AI agents working in that repo see your live mappings, services, and isolation state without having to ask. Files written:
.supbuddy/:README.md,mappings.md,services.md,project.md,mcp.md,do-not.md,docs.md. The full live snapshot, regenerated on each sync.AGENTS.mdandCLAUDE.md: a small managed block prepended (or updated in place) telling the agent which project this is and pointing it at.supbuddy/.- Editor skill files when detected:
.cursor/rules/supbuddy.mdc,.claude/skills/supbuddy/SKILL.md,.codeium/windsurf/rules/supbuddy.md,.continue/rules/supbuddy.md,.github/copilot-instructions.md,.idea/supbuddy.md. .gitignoremanaged block, ignoring:.supbuddy/meta.json(volatile sync state),*.supbuddy-backup-*(rollback snapshots), and the per-editor skill files that are written locally (see scope below). The rest of.supbuddy/is intended to be committed;AGENTS.md,CLAUDE.md, and.github/copilot-instructions.mdare also kept committable since you may have hand-written content there alongside Supbuddy's managed block.
Global vs. local scope
The per-editor skill files are generic Supbuddy-owned pointers ("this is a Supbuddy project: read .supbuddy/, prefer the MCP tools"). For editors that expose a Supbuddy-owned global location, Supbuddy writes that pointer once, machine-wide instead of copying it into every project, so it isn't duplicated across all your repos. Project-specific data always stays local in .supbuddy/.
- Claude Code → one global skill at
~/.claude/skills/supbuddy/SKILL.md. Cursor →~/.cursor/skills/supbuddy/SKILL.md. The global skill self-scopes: it only acts when the working directory has a.supbuddy/folder, and resolves the active project from that folder'smeta.json. - All other targets (
windsurf,continue, theAGENTS.md/CLAUDE.md/Copilot managed blocks, JetBrains) stay local: their "global" files are shared user files, so Supbuddy won't overwrite them. - Each target has a scope setting:
auto(default: global for the Claude/Cursor skills, local for everything else),global,local(force per-project, useful if you commit the file for teammates), oroff. A machine-global file is reference-counted across projects and removed automatically once no project uses it (on disabling sync, deleting a project, or switching that target back to local). Note: uninstalling Supbuddy (e.g. dragging it to the Trash on macOS) does not auto-remove these global files; delete them manually from~/.claude/skills/supbuddy/and~/.cursor/skills/supbuddy/if needed. - The always-loaded
CLAUDE.md/AGENTS.mdmanaged block stays local as a safety net so agents stay aware even if the on-demand global skill doesn't auto-activate.
Sync modes per project:
- Auto: Supbuddy regenerates the files whenever mappings, services, or project state change.
- Manual only: files are only written when you click Sync now (or use the tray's Sync AI context for all projects).
- Off: nothing is written.
The collapsed header shows an at-a-glance status pill: mode (auto / manual / off), a colored dot for the last sync result, and a relative timestamp. Disabled targets (e.g. an editor whose folder isn't present) appear greyed out in the Detected targets list inside the panel.
Supbuddy Cloud
Push a project — its Supabase schema and data — to a hosted cloud dev-stack (its own full self-hosted Supabase — Postgres, Auth, REST, Storage, Realtime, Studio behind a gateway — as an isolated graph of machines on a per-tenant private network) and control it from the app, the CLI, or MCP. Opt-in and per-project: nothing cloud-related appears in a project until you've signed in.
- Invite-only, for now — Supbuddy Cloud is not open signup. You need an invite from the Supbuddy operator; redeeming it creates your own organisation, with you as its owner. Until you redeem one, cloud actions answer "Supbuddy Cloud is invite-only. Redeem your invite code to create your organisation." Org members cannot issue invites — only the operator can.
- Get started — the top bar shows a Get started with Supbuddy Cloud strip; sign in (email/password) there. Once signed in it becomes Open cloud (opens cloud.supbuddy.app in your browser). Sign-in state + the Claude connection also live under Settings → Cloud.
- Push a project — after signing in, each project's ⋯ menu gains Push to cloud…. The dialog previews what the project's env would mean in the box before you commit: values are classified, never rewritten, and it leads with how many point at this machine — those are meaningless inside a box and are the only ones needing a decision. It never blocks the push (a local-looking value may be exactly what you meant) and changes nothing for you. Secrets are listed by name only; their values are never read out of the file. The push ships the project's stack descriptor + a
pg_dumpof its Supabase data (fail-closed: uploaded to a private bucket via a single-use key, sha-verified, restored inside the stack's private network, then deleted). Your local project stays intact — a ☁ badge appears on its row; click it (or ⋯ → Open in cloud) to open the stack in the web app. - CLI / MCP — the same flow headless:
supbuddy cloud login|push|status|teardown(password via arg orSUPBUDDY_CLOUD_PASSWORD), or thepush_to_cloud/get_cloud_status/cloud_teardown/cloud_sign_inMCP tools.project lsmarks pushed projects with ☁, andget_project/list_projectscarry thecloudlink.cloud_teardown(and the ⋯ teardown) destroy the remote stack and unlink it locally — routed through the same plan/apply gate as other destructive tools. - Service breadth — a self-hosted push provisions the full Supabase stack by default. Pass
push_to_cloud'ssupabase_services: "minimal"(MCP) to opt down to a lean db/auth/REST stack instead. - Idle auto-stop — a running cloud stack that reports no activity for ~30 minutes is automatically stopped to save cost (its data + config persist; start it again from the web app). A background reaper also reconciles any stack whose machines went missing.
- Web console — cloud.supbuddy.app lists your org's stacks; open one for its per-service health, live status, and start / stop / restart / tear down controls, plus a Recent activity feed of control-plane events. Push to cloud in the console provisions a stack from a GitHub
owner/repo(self-hosted or bring-your-own Supabase; full or minimal service set) — the code-only path; pushing a local project with its data still goes through the desktop app / CLI.
Public addresses, and renaming them
Each app in a cloud box gets a public address of the form
<app>.<project>.<org>.supbuddy.cloud — for example web.site.acme.supbuddy.cloud. The <app> label
comes from the runner serving that port, <project> and <org> from the slugs you choose. One wildcard
certificate is issued per project (*.<project>.<org>.supbuddy.cloud) and covers every app under it.
- Renaming is allowed while boxes are running. It used to be refused, and for a real reason: a box was told its hostnames once, when its machine was created, and that value cannot be changed afterwards — so a rename left it serving the old names while the new address showed the editor instead of your app. Boxes now ask for their hostnames on each heartbeat, so a running box moves itself, usually within a minute.
- The old address stops working immediately. Its DNS records are removed as part of the rename. This is deliberate: leaving them would make the old URL resolve and quietly serve the editor, which is more confusing than a name that has plainly gone away. Links you have already shared will break.
- A rename can succeed while an address is still moving. Certificates are issued by Let's Encrypt, which limits how often the same set of names can be re-issued (5 per week), so renaming back and forth can hit that ceiling. The rename itself still applies — you will see "The name is changed, but 1 box is still moving to it…" with the reason, rather than a silent half-rename.
- Hostnames need the deployment to be configured for them (
VERCEL_TOKEN,VERCEL_TEAM_ID,SUPBUDDY_CLOUD_BASE_DOMAIN). Without those, boxes are still reachable through the editor and the stack page says "public hostnames are not configured on this deployment" rather than showing nothing.
The Supbuddy panel, inside the box's editor
Every cloud box's editor carries a Supbuddy view in the activity bar — one place to see what the box is doing without leaving it. It reads the status report the box's own supervisor writes, so it adds no credential and no network listener of its own.
- Box (the main view) — the box's phase and uptime, then four sections:
- Apps — the runners this project declared. Each shows its state and port, with Open (in the
editor's browser), Tab (a real browser tab), Start / Stop, Restart and Logs. A
runner that has not started is still listed, because that is usually the one you came to start — and
a runner that failed shows why (for example approved for
aaaaaaaa, HEAD isbbbbbbbbwhen autostart's approved commit no longer matches what the box checked out). - Services — the stack's own Supabase and sidecar services, with where each one lives. These run on separate machines on your private network, so they are probed rather than supervised; one whose first probe has not landed reads checking, not down.
- Configuration — read-only: the repository, the commit, the workspace path, the tailnet name and any public hostnames. Nothing here is editable, because all of it is decided by the plan that built the box.
- System — the box's own plumbing (clone, sshd, tailnet, credential and module installs). It expands itself when something in it is wrong.
- Apps — the runners this project declared. Each shows its state and port, with Open (in the
editor's browser), Tab (a real browser tab), Start / Stop, Restart and Logs. A
runner that has not started is still listed, because that is usually the one you came to start — and
a runner that failed shows why (for example approved for
- Logs open inline, under the app that produced them, and press again to close. A runner with no output says so rather than showing an empty box, and when the supervisor cannot be reached the panel shows its reason instead of failing quietly.
- If the supervisor stops writing, the panel says so — "The supervisor stopped updating 45s ago. What is shown below may no longer be true." A stale report is never rendered as healthy.
- Apps & Services — the original compact tree, still available below the panel and collapsed by default.
Who runs your code inside the box
The editor, its terminal and Claude Code run as coder, which has sudo: the box is your machine.
Code from your repository that the box runs on its own runs as a separate user, runner, with no sudo.
That covers the dependency install, a devcontainer's postCreateCommand and the apps the box starts.
runnercan read and write the project folder, and can read.gitbut not change it.runnercannot open anything else incoder's home: your Claude and Codex sign-ins, the box's own tokens, the editor's settings.- Files either user creates in the project folder stay editable by the other. Some installed files keep
read-only modes (under
node_modules, for example); remove those from the terminal withsudo rm. - git commands inside your apps still work. A setup step that writes into
.gitfails asrunner(for examplehusky install); run it from the terminal instead. - If the box cannot set these permissions, it runs none of the repository's code and says why under
System (
isolation). It does not quietly run that code ascoderinstead.
The separation keeps unattended code away from your credentials. It does not make a repository safe to open:
runner can change files that you later run as coder, such as package scripts, .husky hooks and
node_modules.
When nothing says what to run (no app was detected when you pushed, or the stack was created from the web
console), the box lists one app, web, on port 3000. Starting it runs your project's dev script (or
start if there is no dev script), using the package manager your packageManager field or lockfile names.
If neither identifies a package manager, or neither script exists, the app shows refused with the reason
instead of retrying. The app is expected on port 3000 (PORT is set to it): a dev script that picks its own
port, such as next dev -p 3336, still runs, but that port is not reachable through the app's address. Push
from the Supbuddy app instead, which reads the port from your dev script.
Pushing again, and a box that was rebuilt
Every push approves the commit you pushed: your local HEAD at the time. That includes a push to a project whose
stack is still running. The box's apps start on their own, and its devcontainer setup runs, only for the approved
commit.
A box keeps its project folder across rebuilds, so a rebuilt box can come back with an old checkout. When that happens, it moves the checkout to the approved commit before anything else can touch the folder, but only when it is safe to:
- the checkout is on the branch the box follows, with no local commits, no uncommitted or untracked changes, and no merge or rebase in progress;
- the approved commit is on that branch on GitHub (a commit from another branch is never moved onto it);
- the move is a fast-forward. It never overwrites a file git ignores, and no hook in the repository runs.
Otherwise the checkout is left exactly as it was, and System → checkout in the box's Supbuddy panel says why:
local commits, uncommitted changes, the commit not being on GitHub yet, and so on. If an update is interrupted part
way, the box stops retrying and asks you to check git status first.
New commits on GitHub
When commits land on the box's branch — pushed from Supbuddy or from anywhere else — the box notices within about two minutes, or within seconds of a push from the Supbuddy app. The editor then shows it in three places:
- the status bar:
3 new commits on main, which runs Pull when clicked; - a thin strip at the top of the Supbuddy panel, with a Pull button;
- one notification per push, with Pull and Later. It isn't repeated when you reload the window.
Pull runs in an editor terminal, so you see every step:
- It fetches the branch.
- It sets aside your uncommitted and untracked changes (
git stash). - It fast-forwards. If the box has local commits, it merges them instead, or rebases them if you've set
pull.rebase, just asgit pullwould. - It puts your changes back.
If step 3 or 4 conflicts, Pull lists the files and offers to resolve them with Claude or Codex (continue your last session in this folder, or start a new one), or to leave them to you. The assistant runs interactively in the same terminal, so you see and approve its edits. Pull never commits or pushes for you without asking.
Pull stops before changing anything while a merge, rebase, cherry-pick or revert is in progress.
While live sync is on, the strip still tells you about new commits but offers no Pull. Pull on your own machine instead, and sync brings the changes into the box. Pulling inside the box would rewrite files that sync then copies back to your machine as unexplained changes.
A pull moves the box past the commit you approved, so after a restart its apps don't start on their own until you push again from the Supbuddy app. Starting them from the panel works either way.
Live sync (local ↔ cloud)
Keep a project's local directory and its cloud box in step, so you can edit locally and run in the cloud. Sync runs over a private Tailscale network; nothing is exposed publicly.
- Start it — in the app, a cloud project's ⋯ menu has Start live sync… and Stop live sync. Starting opens a chooser: nothing is preselected and the confirm button stays disabled until you pick a side, because the first pass overwrites one of them.
- Headless —
supbuddy cloud sync start <project> --authority=cloud|local, plusstatusandstop. Same three as MCP tools (cloud_sync_start/cloud_sync_status/cloud_sync_stop). - Supbuddy refuses a cloud-authority sync that would destroy local-only work. The box clones your repository from its remote, so it has never seen uncommitted changes or commits you have not pushed — and the first pass deletes anything the other side lacks. Rather than let that happen, starting sync with the cloud as authority is refused, naming what is at risk and the remedy: "…has 2 uncommitted changes (commit or stash them), and 1 unpushed commit (push them first)." A directory that is not a git repository is refused too, since nothing there could be recovered. Choosing this machine's copy is never blocked — that direction overwrites the box.
- Sync survives restarting Supbuddy. The file synchroniser runs in its own process, so quitting and reopening the app (or an update) does not interrupt a running sync; Supbuddy re-adopts the session on start and the status badge picks up where it left off.
authoritydecides which side wins the FIRST pass, and that pass is one-way. Choose"cloud"when the box has the truth (the usual case — the repo was cloned there) and"local"when your machine does. It has no default anywhere, deliberately: the named side overwrites the other, so a guess can delete work. After the first sync completes, the session switches to two-way automatically.- What is not synced —
.git,node_modules,.next,distand.turboare ignored by default..gitin particular: the box has its own clone with its own remote, and syncing two managed copies of an index produces conflicts that look like repository corruption. - Requirements — sync needs the
tailscaledandmutagenplatform packages, which install automatically with the CLI on macOS and Linux (Intel and Apple Silicon / x86-64 and arm64). Windows is not supported yet, and Supbuddy says so rather than reporting a missing package. Without the packages Supbuddy reports sync as unavailable and everything else keeps working. - Your own Tailscale is untouched. Supbuddy runs its own tailnet daemon with a separate state file and socket, so joining does not log you out of a personal or work tailnet.
- Teardown stops sync first, and the box's tailnet node is removed with the stack — nothing outlives a destroyed stack.
- Seeing it — a syncing project shows its state beside the ☁ badge: First sync…, In sync, n conflicts, or Paused — stack stopped when the box has been idle-stopped. Nothing is shown for a project that is not syncing.
- If sync is unavailable, everything else keeps working. Provisioning, the IDE, runners and teardown do not depend on the sync network; a stack simply comes up without sync and says so.
Command-line interface (CLI)
Everything the desktop app can do is also driveable headlessly from a terminal, with no GUI window. The CLI runs a daemon (the same worker process the GUI uses: Caddy proxy, DNS, Supabase/Compose lifecycle, MCP-HTTP) and a set of commands that attach to it over the local MCP-HTTP port. This is for SSH sessions, CI, tmux/server boxes, and scripting.
The binary is supbuddy, with a short alias sup. Run supbuddy help for the full usage list.
You can install the CLI on its own, without the desktop app:
npx supbuddy@latest # asks to install the CLI globally (supbuddy + sup)
That command does nothing on its own except offer to put supbuddy and sup on your PATH. The CLI runs independently of the desktop app, so you can add the app later (or never). On a Mac the app installs the same two commands for you.
The daemon
supbuddy daemon --detach # start the worker in the background
supbuddy status # daemon + proxy health, plus which worker the daemon is running
supbuddy version # which CLI build this is, and which daemon it is talking to
supbuddy stop # graceful shutdown
supbuddy version answers a question that used to have no answer: which copy of the CLI is this? Three builds exist and they look identical — the one inside the desktop app (host), the one from npm (npm), and one built from a checkout (dev). The build kind is stamped in at compile time, because nothing at runtime can tell them apart: the version numbers match, and a working-tree build even carries the same daemon/worker.cjs layout as an npm install. It prints the CLI's version, build kind and path, plus the daemon's, and warns when the two disagree — a dev CLI driving a shipped daemon means unreleased code is running privileged repairs against your real machine.
The names supbuddy and sup are reserved for shipped builds. A dev build invoked under either name refuses to run and explains how to find the shadowing symlink, because pnpm link or a hand-made symlink in a directory that precedes /usr/local/bin on PATH otherwise silently replaces the installed CLI. To run a checkout, use ./scripts/supbuddy-dev <command> — it runs from source and needs no build. It deliberately shares the production state dir: a daemon's machine-level resources (the worker port, the Caddyfile, /etc/hosts, /etc/resolver, the pf anchor, the launchd label) are not state-dir scoped, so pointing a dev daemon at a private state dir does not isolate it — it only hides the running daemon from the single-daemon check, after which the dev worker takes port 48760 by killing the process holding it. Sharing the state dir keeps that check working, so supbuddy-dev daemon declines while the app's daemon is running. A dev CLI driving a shipped daemon prints a warning on every command.
--detach backgrounds the daemon and prints its pid + ports. Foreground supbuddy daemon runs it attached (Ctrl-C shuts it down cleanly). On start the daemon writes a discovery file, daemon.json (mode 0600), into the shared state dir holding its pid, the Socket.IO port, the MCP-HTTP port, and a control token; every other command reads it to find and authenticate to the daemon, so you never pass ports or tokens by hand. Only one daemon may run per state dir; a second daemon start is refused.
The CLI and the desktop app share one state dir (~/Library/Application Support/Supbuddy/), so they manage the same projects, mappings, and settings. They must not run two workers against it at once: if you launch the desktop app while a CLI daemon is running, the app detects it and offers to stop the daemon and continue or quit. It never forks a competing worker (which would corrupt state.json).
After the app updates itself, it replaces an outdated daemon. The daemon is detached, so it survives the app relaunching — without this the app would look updated while still running the previous version's worker, and any fix shipped in that worker would silently not take effect. On launch the app compares the running daemon's version (stamped into daemon.json) against its own: an older daemon is stopped and replaced, and a newer one is left alone and attached to, since an out-of-date app must not downgrade a running worker. If the daemon ignores the graceful stop, the app forces it rather than carrying on as though the stop had worked — attaching to the daemon it just judged stale is exactly how an updated app ends up running old code, and the replacement spawn would be refused anyway ("already running"). Shutdown is bounded from the other side too: every stop step has a timeout and the worker exits even when a service refuses to stop, because a daemon that cannot be stopped cannot be updated. A forced shutdown may leave Caddy briefly running; the health monitor reaps it and the replacement daemon takes over.
Run on login (service)
supbuddy service install # start-on-login (launchd on macOS, systemd-user on Linux)
supbuddy service status
supbuddy service uninstall
Commands
All app surfaces have a command. Names follow supbuddy <module> <action> [args] [--flags]. The main groups:
| Group | Examples |
|---|---|
| Dev launcher | run [--print] -- <dev command> — on a Thin project, binds the dev server to the project's loopback IP (from .supbuddy/meta.json) so it keeps its canonical port (e.g. supbuddy run -- next dev stays on :3000) |
| Health / proxy | status, doctor [--fix] (health & drift scan — see System doctor), ready [<proj>] [--fix] [--only=<category>] (can a project run? — see Project readiness (supbuddy ready)), reset [--tier=soft|deep|full] (tiered system reset — see System reset), proxy status|start|stop|restart |
| Mappings | map ls|add|get|set|enable|disable|rm|restore |
| Projects | project ls|add|get|scan|set|enable|disable|rm|restore|env|refresh-context |
| Supabase | supabase start|stop|restart|status <proj> (add --follow to stream live progress), supabase config apply <proj> <file> |
| Cloud | cloud login <email> [<pw>] (or SUPBUDDY_CLOUD_PASSWORD), cloud push <proj> [--repo=owner/repo] [--force], cloud status [<proj>], cloud teardown <proj> — push a project (with its Supabase data) to a hosted cloud stack; project ls marks pushed projects with ☁ |
| Compose | compose up|down|restart|status|logs <proj> [svcs] |
| Scripts | scripts ls|start|stop|restart|logs|bookmark <proj> [script] |
| Isolation | isolation switch <proj> <host|thin>, isolation pending-migrations, migrate start|finish <uuid> |
| Certificates | ca status|install|uninstall |
| Env files | env copy <src> <key> <target>, env write <path> <K=V>… |
| Settings | settings get, settings set --json <patch> |
| MCP | mcp add [<agent>] (register Supbuddy into a coding agent: interactive, or --write/--print/--prompt), mcp ls, mcp revoke <id>, mcp approvals apply|cancel <id> |
| Host / network | connect, trust, tailscale, dns, pf (port-forwarding) |
| Logs | logs requests [-f], logs audit [-f], logs get <id> |
| Account | account, caps, addons scaffold|seed <proj> |
| Dashboard | tui (alias dash), shell (alias menu) — see Live dashboard (TUI) |
Global flags: --json (machine-readable output), --yes (skip confirmations), --quiet, --url/--token (attach to a specific/remote daemon instead of auto-discovery), --state-dir (override the shared dir), --timeout, and -f/--follow for streaming log commands and live supabase start|stop|restart progress.
Destructive operations go through the same plan → apply gate as MCP (see Plan / apply for destructive tools); the CLI's control token is granted auto-apply, so they execute directly.
Live dashboard (TUI)
supbuddy tui # the full-screen dashboard; alias: dash
supbuddy shell # the interactive picker; alias: menu
tui opens a full-screen terminal dashboard that attaches to the running daemon and shows live connection/proxy status, the project list (with each project's isolation, Supabase, and Compose state), the mapping count, and a tail of recent requests. Press r to refresh, q to quit. It needs a running daemon (supbuddy daemon --detach); if none is found it tells you so.
shell opens an interactive picker over the same command tree — modules on the left, that module's actions on the right, type to filter. The command you choose runs in the parent shell, so its output lands in your terminal rather than scrolling past inside a full-screen app. A bare supbuddy, or a bare module like supbuddy proxy, opens the same picker when you are on a terminal.
It ships in every build, including the npm CLI — which is the point, because an SSH, CI or server box has the CLI and nothing else. The dashboard and the picker are a React/Ink program bundled into a single ~550 KB file (tui.js) that sits beside the CLI in the package, so npm install -g supbuddy is all it takes and nothing extra is downloaded at runtime. It adds no runtime dependencies. If an install is incomplete and that file is missing, tui, dash, shell and menu say so and exit non-zero rather than exiting 0 having done nothing, and they are left out of that install's --help and shell completion.
Both need an interactive terminal. They take over the terminal and put stdin into raw mode, so when stdin or stdout is not a TTY — piped, redirected, or a CI job — tui, dash, shell and menu print why and exit non-zero instead of half-rendering a frame and reporting success for a screen nobody can see. On those boxes use the non-interactive commands below, with --json where a script is reading.
Without the TUI, supbuddy status, supbuddy project ls, supbuddy map ls and supbuddy logs requests -f report the same information, as does the desktop app window.
System doctor
supbuddy doctor # read-only scan; prints findings by severity
supbuddy doctor --fix # scan, show the repair manifest, confirm (y/N), then apply
supbuddy doctor --fix --only=ca-not-trusted # restrict repairs to specific check ids (comma-separated)
supbuddy doctor --fix --yes # skip the interactive confirm (scripting / CI)
supbuddy doctor runs a read-only health and drift scan and prints its findings grouped by severity — critical, warning, info — each with a title, a one-line detail, and concrete evidence (paths, container names, certificate fingerprints). The scan mutates nothing, so you can gate a script or CI on it.
Exit codes. A check that can't run is an unknown, not a clean bill of health — so the scan reports "I couldn't look" separately from "I looked and it's fine":
| Code | Meaning |
|---|---|
0 | The scan completed and found nothing critical |
1 | Critical findings — something is definitely broken |
2 | The scan could not complete — one or more checks never ran (see SCAN ERRORS in the output), so the result is an unknown |
Exit 2 covers cases that used to (wrongly) exit 0: with Docker stopped, for example, every Docker-backed check fails to run, and a 0 there would tell CI the machine was healthy while part of the scan was blind. A critical finding outranks an incomplete scan — if both apply you get 1, because that's the actionable one. Gating on "non-zero" catches both; check for 2 specifically if you want to start Docker and retry rather than fail the build. These codes apply to --fix too: a run where every repair applied but part of the scan never ran also exits 2.
--fix re-scans, prints a manifest — one line per fixable finding, taken from the scan you just saw — and, unless you pass --yes, asks Apply these fixes? [y/N] (default No) before touching anything. (The desktop app's doctor panel shows the finer-grained repair actions themselves; the CLI lists the findings those actions belong to.) --only=<comma,ids> restricts the repair to specific check ids; --yes skips the prompt for non-interactive use. This is the confirm-before-harm contract: the scan is read-only, and every repair is opt-in and gated. Fixes that need elevated access prompt for your password when they run.
A repair that ends up doing nothing is reported as such, never as success: if a requested check's finding is already gone, is advisory, can't be re-checked, or names an unknown id, it's listed under NOT APPLIED and the command exits non-zero.
An aborted --fix also exits non-zero (1). Declining the confirmation applies nothing, so every finding is still there — exiting 0 would tell a script the machine was fine when it had just been reported as critical. This matters most where nobody actually declined: with no TTY to prompt on, --fix refuses on principle (confirm-before-harm), so a scripted run prints aborted — no fixes applied and stops. Pass --yes to run it unattended. A daemon-side denial of the confirmation has always exited 1; the same outcome now gets the same code regardless of which side refused.
The doctor ships 22 checks. Rows marked Advisory have no auto-fix at all: --fix will never touch them, and the finding's detail tells you what to do by hand. Checks marked macOS return nothing on other platforms.
| Check id | Severity | What it flags | Auto-fix |
|---|---|---|---|
state-corrupt | critical | state.json can't be parsed (or isn't an object), so the daemon boots with empty state — no projects, mappings, settings or MCP clients | Copies the file aside as state.json.corrupt-<timestamp> so you can hand-recover it. Nothing is deleted or rewritten |
dns-not-resolving | critical | Supbuddy serves these domains but the OS will not resolve them, so every mapped URL fails before it reaches the proxy — a missing /etc/resolver file, the local DNS server not answering, or (the case a file audit calls healthy) the files being correct while the OS has never loaded them. Leftover files for suffixes nobody uses are not this — they break no resolution and belong to stale-resolver-files. Uses the same verdict get_health and get_proxy_status use, so the three cannot disagree about one machine | Advisory — no auto-fix. supbuddy proxy restart rewrites the resolver files and reloads the OS cache. The available privileged re-apply is audit-gated — it does nothing when the files are already correct, which is exactly the unloaded case — so offering it as a fix would elevate, change nothing and report success |
dns-local-tld-mdns-stall | warning | macOS. Managed .local domains resolve fast once and stall ~5s per concurrent lookup — macOS reserves .local for multicast DNS and a resolver file does not stop it. Only the IPv6 (AAAA) half stalls, so curl, a single fetch and dig all look healthy while a page issuing parallel requests fails with what looks like a proxy connect timeout. Advisory. The check measures rather than lints — 8 parallel lookups against a real mapping — so it stays silent on a machine that is genuinely unaffected. Fix by moving off .local: supbuddy project set <project> --tld=test | |
proxy-not-serving | critical | The proxy should be serving and nothing is — Caddy is not alive, so every enabled mapping is unreachable. It stays silent when Caddy is up but a privileged step failed (HTTPS still serves on the high port there, and pf-not-enforcing describes that state precisely) — two contradictory critical findings would teach you to ignore both. It reads the same derived status get_proxy_status does, so the two can never disagree about the same machine: a deliberate proxy stop and an in-flight auto-restart are not flagged | Advisory — no auto-fix. The finding carries the tracked cause and names both routes back: supbuddy proxy restart (or Start in the app), and SUPBUDDY_ASKPASS when the cause is a privileged step that needs a TTY. Starting the proxy is the step that failed, so --fix would re-run the failing path |
caddy-stuck | critical | Caddy is alive but its admin API is wedged, so config reloads can't land | Restarts Caddy (stop → start) |
caddy-ipv4-unreachable | critical | Caddy's loaded config declares an HTTPS listener but 127.0.0.1:<port> refuses connections — every IPv4 client is cut off (browsers, curl, and the pf 443→8443 redirect) while the process is up and its admin API answers | Advisory — no auto-fix. Run supbuddy proxy restart to rebind. Only a connection refused counts: a timeout on a pf redirect target is normal (the reply is reverse-NAT'd back to :443 and never matches your socket), so it is never reported as a fault |
ca-not-trusted | warning | The local CA exists but the current root isn't trusted (the padlock stays broken). Detection is by fingerprint and reads both the System and login keychains, so neither a stale same-name root from an earlier CA nor a per-user install is misread | Installs it (security add-trusted-cert; asks for your password — on macOS 15+ this becomes the per-user install with its own confirmation dialog). Where trust cannot be read at all (Windows, or an unreadable keychain) this drops to advisory, info, no auto-fix — it reports what to import by hand rather than offering a repair that can't run |
pf-not-enforcing | critical | Port forwarding is configured but 443 isn't redirecting, so every https:// URL on the default port is unreachable. It first checks that the HTTPS port has a listener: with nothing serving behind the redirect a closed 443 says nothing about pf, so that machine gets no finding rather than a false one | Fixable. doctor --fix re-applies the pf ruleset (asks for your password) and then probes 443 to confirm — it reports success only if the redirect actually answers. By hand: sudo pfctl -f /etc/pf.conf. supbuddy proxy restart also re-applies it now, but only when a probe says it is genuinely broken, so an ordinary restart still prompts for nothing |
duplicate-caddy-ca | warning | macOS. Stale same-name Caddy Local Authority roots with a different key — the cause of Firefox-family SEC_ERROR_BAD_SIGNATURE | Deletes the stale roots and installs the current one in a single elevated batch (asks for your password). Delete-only could leave a machine with no trusted Caddy root at all when the current one wasn't in the keychain yet |
orphan-caddy-container | warning | A leftover pre-binary-era supbuddy-caddy Docker container | Removes the container, its supbuddy-net network and its data/config volumes (the caddy:latest image is kept) |
orphan-lo0-aliases | warning | macOS. 127.0.0.N aliases on lo0 owned by no Thin project — deleting a Thin project never tore its alias down | Removes only those aliases (asks for your password); 127.0.0.1 and any non-Supbuddy alias are left alone |
orphan-dind | warning | Docker-in-Docker containers from the retired Isolated (VM) mode belonging to no registered project — each one confirmed to actually be a DinD first | Force-removes those containers and their <name>-docker data volumes. This is project data: if you deleted a project and chose to keep its data, this is that data. The Caddy container and non-Supbuddy containers are never touched |
orphan-supabase-volumes | warning | Docker volumes of Supbuddy-managed (sb--prefixed) Supabase stacks owned by no registered project | Removes those volumes. This is database data. Host-mode stacks, stacks you started yourself, and projects still in the MCP trash (restorable for 7 days) are never touched |
orphan-launchagents | warning | macOS. Legacy CA-trust LaunchAgents from older builds that re-export SSL_CERT_FILE / REQUESTS_CA_BUNDLE / NODE_EXTRA_CA_CERTS at every login and break public TLS | Boots each agent out and removes it, leaving a .supbuddy-backup copy alongside. Root-owned agents under /Library may resist; the fix reports those as a failure instead of claiming success |
orphan-electron-token-files | warning | Leftover ~/.config/Supbuddy/mcp/<clientId>.bin token files from the retired Electron app, for clients that no longer exist | Deletes those files (no elevation). They can't be decrypted any more anyway; clients that are merely revoked keep their record and are left alone |
orphan-mcp-secrets | warning | secrets/mcp-<clientId>.secret files whose token can no longer authenticate (client revoked, or no record at all) | Deletes those files (no elevation) — it can't log a working agent out. Secrets for current clients, and the non-MCP secrets stored alongside them (license, cloud session, Tailscale key), are left untouched |
projects-not-ready | warning | Registered projects that wouldn't actually run — dependencies never installed (or a lockfile newer than the last install), or Supbuddy's Supabase connection vars missing from the env file the app loads. One aggregate line, never one finding per project, with project names only as evidence. A project whose folder is gone is skipped; one Supbuddy couldn't read is listed as (could not be scanned) rather than passed as healthy | Advisory — no auto-fix. It points at supbuddy ready and the project card, which carry the specific finding, the file it names and the one-click fix. Doctor installs nothing |
unmanaged-supabase | info | A Supabase stack on the host daemon that maps to no registered project (e.g. a plain supabase start) | Advisory — no auto-fix. Supbuddy never tears down a stack you started yourself; run supabase stop in its project if you don't need it |
stale-resolver-files | info | macOS. Supbuddy-marked /etc/resolver/<suffix> files for suffixes no enabled project or mapping claims any more (deleted projects, a disabled one, an older per-project TLD) | Removes only those files (asks for your password); suffixes still in use are left alone. Reversible — enabling the project or restarting the proxy writes the file back |
pf-conf-backups | info | macOS. /etc/pf.conf.backup.<timestamp> copies piled up in /etc by older versions (which wrote a new one on every port-forwarding disable) | Removes the redundant copies, keeping the newest one and the stable /etc/pf.conf.supbuddy-backup (asks for your password) |
stale-mcp-config-tokens | info | An agent config (~/.claude.json, Claude Desktop, Cursor, Codex, Windsurf, or a registered project's .mcp.json / .cursor/mcp.json) holds a mcpServers.supbuddy token Supbuddy no longer accepts — the 401 "Token not recognized" state | Advisory — no auto-fix. Supbuddy won't rewrite config files you own and edit. Delete the mcpServers.supbuddy entry from the file named in the finding, or run supbuddy mcp add <agent> to mint a fresh token. The finding names the file, never the token |
stale-browser-nss-roots | info | macOS. A Firefox / Zen / LibreWolf / Waterfox profile whose own NSS store (cert9.db) holds a Caddy Local Authority root Supbuddy can't reach | Advisory — no auto-fix. Nothing is wrong unless that browser shows certificate errors. Fix it there: Settings → Privacy & Security → Certificates → View Certificates… → Authorities, delete every Caddy Local Authority entry, then re-import Supbuddy's CA |
The same scan and repairs are available over MCP as the doctor and doctor_fix tools (see MCP tool surface), and in the app under Settings → General → System health → Scan — the panel scans on open, groups the findings by severity, and gates every repair behind the same manifest + confirm step (see Settings reference → General). The panel has no reset button: a wipe stays a CLI operation.
Project readiness (supbuddy ready)
supbuddy ready # every registered project
supbuddy ready acme # one project (id, or an unambiguous name)
supbuddy ready acme --fix # show a manifest, confirm (y/N), then apply each fix
supbuddy ready acme --fix --only=deps # deps | env | toolchain
supbuddy ready acme --fix --yes # skip the interactive confirm (scripting / CI)
supbuddy ready --json # machine-readable
supbuddy ready answers "can this project actually run?" — see Project readiness for what it checks. It is presented like supbuddy doctor: findings grouped by severity (ERROR, WARNING, INFO), a [fixable] marker, evidence indented beneath each finding, and a SCAN ERRORS block for projects it could not scan. Without --fix it mutates nothing.
--fix differs from doctor --fix in one way that matters: doctor sends a list of check ids back to one repair tool, while each readiness finding already names its own tool and arguments, so ready --fix calls that. A yarn project gets yarn install, a pnpm project pnpm install, an env finding gets apply_env — the CLI picks none of it. You see the manifest of what will run before anything does; n (and a non-interactive terminal without --yes) aborts having changed nothing.
Because an install is a job rather than a call, --fix then follows the install log to completion and takes that job's outcome as the fix's outcome, so the command blocks with live output instead of exiting 0 over an install that is still resolving.
Exit codes mirror doctor:
| Code | Meaning |
|---|---|
0 | Ready — no error-severity findings |
1 | An error-severity finding, or a fix that failed |
2 | The scan could not complete (see SCAN ERRORS), so the result is an unknown |
--only narrows the verdict as well as the view: a run filtered to deps exits on the deps findings alone, because a command that printed three findings and exited on a fourth it never showed you would be lying.
Related: supbuddy project ls now shows a project's connection-env verdict (env missing / env stale) on its sub-line. It was previously hidden from that list entirely, so the CLI could not tell you the one fact that explains why an app 500s against a stack that is up. Only the verdict is printed — never the target path or a KEY=value — for the same reason readiness evidence never carries values.
System reset
supbuddy reset # soft (the default): app state + caches
supbuddy reset --tier=deep # + services, Caddy containers, system integrations, CA trust
supbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data
supbuddy reset --tier=deep --yes # skip the y/N confirm (scripting / CI)
supbuddy reset --tier=full --yes --i-understand # the ONLY scripted path for a full reset
supbuddy reset removes Supbuddy's footprint from your machine in tiers, and each tier is a superset of the one before it:
| Tier | What it removes |
|---|---|
soft (default) | Stops the dev servers Supbuddy started (it is about to forget which project owns them), then resets app state — projects, mappings, settings, MCP clients, project-context sync and user-skill records — plus the Docker image cache (<app-data>/image-cache, images are re-pulled on demand) and the buffered request log. It touches no Docker container or volume, nothing under /etc, and no file in your repos, so it never asks for your password |
deep | …plus: stops every service; removes the leftover Caddy container/network/volumes, both /etc/hosts blocks (the legacy # Supbuddy - Start one and the # Supbuddy DNS fallback one), the /etc/resolver files, the pf :80/:443 redirect, the 127.0.0.N loopback aliases, the bundled-runtime CA trust and the Caddy Local Authority roots in your keychain, and the token files of already-revoked MCP clients. Your data is preserved: no Supabase volume, no DinD container, no repo file and no live MCP token is touched — deep unwinds what Supbuddy installed on the machine, it is not a data wipe |
full | …plus your project data, backed up first: every Supbuddy-managed (sb--prefixed) Supabase stack's data volumes and every DinD container with its data volume, the .supbuddy/ directories, managed blocks and .env.supbuddy files in your registered repos, and every credential (license, live MCP tokens, cloud session, Tailscale key) — then it uninstalls the start-on-login service and empties the app-data directory. A host-mode project's Supabase stack is only stopped: those containers and volumes are yours, and they are kept |
Most steps enumerate what's actually on your machine first, so anything that isn't there drops out of the manifest instead of being advertised and skipped. soft needs no elevated access at all. deep batches the pf redirect, the resolver configuration (including the /etc/hosts fallback block, which is removed by the same command) and the loopback aliases into one password prompt; the legacy /etc/hosts block and the keychain CA removal ask separately, so expect up to three. full may prompt more than once as it tears projects down.
Running dev servers are stopped first — except on --tier=full. Every tier forgets your projects, and a pnpm dev that outlives that row holds its port against whatever you register next, keeps writing into a directory Supbuddy no longer knows about, and can no longer be stopped from the app or with supbuddy scripts stop (both look the project up first). So soft and deep sweep them before resetting the registry, and the manifest names each one. --tier=full cannot: it stops the daemon before the wipe runs (it has to — a live daemon would rewrite state.json underneath it), and those dev servers are the daemon's own children, tracked only in its memory. Stop them yourself — in the app, or supbuddy scripts stop <project> <script> — before a full reset, or they outlive it as orphans you have to kill by pid.
Reset is a CLI operation, on purpose — there is no reset button in the app. The gates that make a wipe safe don't survive the trip into a GUI: a typed RESET, a refusal on non-interactive input, and a daemon confirmation the app itself would be answering. On top of that, --tier=full refuses outright while the desktop app is running (its watchdog respawns the daemon ~20s after it stops), so a button for it would be a trap. The app's Settings → General → System health panel points here instead.
Backup before harm. Anything you can't regenerate — state.json, every managed Supabase database that is running (pg_dump, custom format, with a .sha256 alongside), every managed data volume (tar.gz, verified with gzip -t) — is written to <app-data>/backups/reset-<timestamp>/ before a single destructive step runs, and if any backup fails the whole reset aborts before destroying anything. The directory is printed prominently before you confirm, and again when the reset finishes; manifest.json inside it records exactly what was planned and what ran. On top of that coarse guarantee, each volume is gated individually: no archive, no removal — a volume with no non-empty .tar.gz next to it is left alone and the run records why.
A backup that can't be written stops the reset — safely. Archiving a volume is given ten minutes; a genuinely large one (tens of GB of Postgres data plus a DinD image cache) can exceed that, and when it does the reset aborts with nothing destroyed. Stop the stack and prune what you don't need (docker system prune, drop old branches/schemas), or archive that volume yourself, then run the reset again. The same applies to any other backup failure: a full disk, an unreadable volume, a Docker daemon that stops answering.
The backups survive a full reset. They live inside the app-data directory, so the last step of --tier=full empties that directory content-wise and skips backups/ rather than deleting it wholesale. Move that directory somewhere safe afterwards — it's the only copy.
Confirmation. Every tier prints the manifest first — the literal list of actions that will run, derived from the same actions the engine executes. soft and deep then ask Apply this "<tier>" reset? [y/N] (default No); --yes skips that prompt. --tier=full requires you to type the word RESET — --yes alone does not bypass it. The one scripted path for a full reset is --yes --i-understand, both flags together. Every prompt refuses on a non-interactive (piped) stdin rather than proceeding.
The daemon confirms too. soft and deep run inside the daemon, which asks for its own approval before it starts — the same gate as doctor --fix and ca uninstall. With the Supbuddy app open you get a native Allow / Deny dialog. A daemon with neither a dialog nor a terminal — the start-on-login service, or an app-spawned daemon while the app is closed — has nobody to ask and denies; run a foreground supbuddy daemon in one terminal and the reset from a second, and it will prompt there. Don't reach for supbuddy daemon --yes to get past it: that auto-approves every confirmation for that daemon's whole lifetime.
Quit the app before a full reset. The desktop app supervises the daemon and restarts it about 20 seconds after it stops, which would put a live daemon back into the directory the last step clears. --tier=full refuses up front while the app is running — before it asks you to type RESET, and before it changes anything. Quit the app (menu bar icon → Quit) and run it again; the quit dialog's default Leave running is fine, since the reset stops the daemon itself. The check looks for the app process only, so nothing else has to change. --tier=full also runs with no daemon at all, so if you quit with Stop service you can go straight ahead.
The order of a full reset, once you've confirmed: the start-on-login service is uninstalled, the daemon is stopped and waited for (the reset refuses to run against a live daemon, which would rewrite state.json underneath it), the backup and teardown steps above run, and only then is the app-data directory emptied — keeping backups/. If the reset aborted, or if a daemon came back while it was running, the app-data directory is left in place and the CLI tells you so rather than clearing it under a live process.
soft and deep are also available over MCP as the plan-gated system_wipe tool (see MCP tool surface). --tier=full is CLI-only: it deletes the credentials any agent would be calling with, and a daemon cannot uninstall the service it runs under or delete the directory it runs from.
What a full reset does not remove. It only ever touches paths of registered projects — there is no disk scan for stray .supbuddy directories — and it won't delete or rewrite files whose ownership is ambiguous. So after --tier=full these are still on disk, and you can remove them by hand:
- Per-editor rule files Supbuddy wrote in your repos:
.cursor/rules/supbuddy.mdc,.claude/skills/supbuddy/SKILL.md,.codeium/windsurf/rules/supbuddy.md,.continue/rules/supbuddy.md,.idea/supbuddy.md. Shared files (CLAUDE.md,AGENTS.md,.gitignore, …) keep their content and only lose Supbuddy's sentinel-delimited block. - Values
apply_envmerged into your own.env*files. The fully-owned.env.supbuddyfiles are deleted. - The bare
.env.supbuddyline in.gitignore— it sits outside the managed block. vite.config.*allowedHostsandnext.config.*dev-origin patches.supabase/config.tomlport /project_idpatches, when restoring the original file failed during the Thin teardown.- MCP client config entries written by
mcp add/install_mcp_config(~/.claude.json, Claude Desktop, Cursor, Codex, Windsurf, a project.mcp.json/.cursor/mcp.json). The token they hold is dead the moment the secrets are deleted;supbuddy doctor'sstale-mcp-config-tokenscheck will name each file. - The
caddy:latestDocker image (shared and re-pullable) and anything a host-mode project owns. - The Supbuddy app itself — drag
Supbuddy.appto the Trash — and the backups directory, which is the whole point of keeping it.
Settings reference
Open Settings via the gear icon top-right or by clicking the tray icon → Open Dashboard → gear. Five tabs.
General
- Theme: dark or light.
- Auto-start at login: registers Supbuddy as a macOS login item. Default: on.
- Default TLD: applied to new auto-generated mappings. Existing mappings are renamed to the new TLD on save. Default:
test. - Default isolation:
hostorthinfor newly added projects. Default:thin(per-project loopback IP; apps keep canonical ports like:3000). MCP registration additionally keeps a project onhostwhen its Supabase stack is already running on the host outside Supbuddy. - Auto-subdomain mapping: when on, services and apps detected during a project scan get mappings created automatically. Default: on.
- Bundled-runtime trust: installs Supbuddy's local root CA into a place that apps with bundled JavaScript runtimes (Claude Code, Cursor, Windsurf, Continue, Codex CLI, OpenCode, …) actually read. These apps don't consult the system Keychain (they ship their own Mozilla bundle), so without this they fail OAuth/MCP/HTTPS calls to
*.testwithunable to get local issuer certificate. Default: prompted on first launch when one of those tools is detected.- macOS: writes
~/Library/LaunchAgents/com.cueplusplus.supbuddy.bundled-runtime-ca-trust.plistand callslaunchctl setenv NODE_EXTRA_CA_CERTSso GUI-launched apps inherit it at process-start time. - Linux: writes
~/.config/environment.d/supbuddy-ca.conf(read by systemd-aware user sessions on GNOME/KDE/Sway/etc.). - Windows: per-user
setx NODE_EXTRA_CA_CERTStoHKCU\Environment. - Only
NODE_EXTRA_CA_CERTSis set session-globally, because it is additive — Node appends the file to its built-in public roots, so a stale or wrong value can never strip public trust.SSL_CERT_FILE/REQUESTS_CA_BUNDLEare deliberately not set globally: they replace the entire trust store, and pointing them at a local-only bundle breaks every public TLS handshake in the login session. Older builds did set them; install and every boot reconcile now actively unset them. OpenSSL/Python tools that need local trust get it per-project, from the merged public+local bundle. - It points at
~/Library/Application Support/Supbuddy/ca-bundle/current.crt(or the platform equivalent), a cumulative concatenated PEM Supbuddy maintains — not Caddy's owncaddy-data/…/pki/authorities/local/root.crt, which rotates independently. When Caddy rotates its root (yearly today, sometimes more), Supbuddy appends the new root automatically; long-running TLS contexts holding the old root keep working until the process restarts. Reading trust status also verifies Caddy's active root is actually in the bundle and re-appends it if not, so a rotation can't be missed just because the file watcher wasn't running. - Test trust: runs an in-process HTTPS request against the first available
*.testmapping with the same env vars set, to verify end-to-end without relaunching anything. It probes the real access path (port 443 when port forwarding is on, otherwise the high port), matching what real clients hit, so it doesn't false-negative against a port nothing is forwarding. - Effective-value detection: status reports the value in effect, not just the one Supbuddy set.
launchctl setenvcannot retro-patch an already-running process, so an app launched before an install keeps whatever it captured and hands that to every shell and dev server it spawns — a terminal can be using a completely different CA path from the onelaunchctl getenvprints. Supbuddy samples three places: what it set, what a fresh login shell resolves, and what live processes actually hold. Divergent values are listed with the app to relaunch (and flagged when the file no longer exists — Node ignores a missingNODE_EXTRA_CA_CERTSsilently, which presents asunable to get local issuer certificatewith nothing to explain it). - Conflict refusal: if
NODE_EXTRA_CA_CERTSis already set to a bundle Supbuddy doesn't own (corporate proxy, Zscaler, another vendor's CA), install refuses and surfaces the conflicting path. You can override with the explicit prompt that pops up on Install. A path Supbuddy does own but that isn't the current bundle — an older build's value, or Caddy'sroot.crtfrom a hand-rolled setup — is not a conflict: install corrects it. - Quit and relaunch your AI tools after install: the env var only takes effect for newly-launched processes. Install names any app still holding an older path.
- macOS: writes
- System health (Scan): opens the System Doctor panel — the same read-only, 17-check health & drift scan as
supbuddy doctor(see System doctor), in the app. Opening the panel only scans; it changes nothing.- Findings are grouped critical → warning → info, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. Rescan re-runs the scan; the header shows the counts. A scan that times out says so and points at
supbuddy doctor— the daemon is installed and updated separately from the app, and one older than this panel doesn't answer its channels. - Fix… on a fixable finding — or Fix all (n) in the header — never repairs anything by itself. It opens the manifest: the literal list of actions that would run, each marked destructive or safe, built from the same actions the engine executes. Apply stays disabled until that manifest has loaded and contains at least one action, so an empty or failed plan can't be rubber-stamped. Same confirm-before-harm contract as
doctor --fix. - Repairs that need elevated access ask for your password when they run. One that outlives the app's 15-second reply window (a password prompt sitting open) is reported as may still be running — rescan in a moment, not as a failure.
- Findings with no auto-fix show advisory instead of a Fix button; the detail says what to do by hand. Checks that couldn't run at all are listed at the bottom as Checks that could not run, rather than being silently dropped.
- There is no reset button here, on purpose — the footer points at
supbuddy resetinstead. See System reset.
- Findings are grouped critical → warning → info, each with its title, one-line detail, concrete evidence (paths, container names, fingerprints), check id and category. Rescan re-runs the scan; the header shows the counts. A scan that times out says so and points at
Network
- HTTP port: default 8080.
- HTTPS port: default 8443.
- DNS port: default 5353.
- Port forwarding: when on, inserts a
pfctlrule mapping 80→HTTP port and 443→HTTPS port into/etc/pf.conf(correct translation-section placement; self-heals a file corrupted by older versions). Asks for sudo once. Status reflects a live 443 enforcement probe, not just file presence. - LAN sharing: binds Caddy to
0.0.0.0+ starts mDNS responder. - Tailscale: paste a tailnet API key to enable split-DNS push.
- Install / Uninstall CA: Install adds Caddy's root cert to your System keychain (removing any stale same-name roots first), falling back to your login keychain on macOS 15+ where system-wide trust needs a dialog macOS won't show a background helper; Uninstall removes every
Caddy Local Authorityroot it added, from both keychains. macOS asks for your password each time. If Install can't complete, the exact command to run yourself stays pinned under the row rather than only in a toast.
Storage
Trash retention (per-kind), volume sizes, image-cache controls.
MCP
- Clients: list of connected clients. Each row has a ⋯ actions menu: install, edit scopes, set-primary, rotate token, revoke.
- Activity: audit log with Apply/Cancel/Undo on plan rows.
- Trash: soft-deleted mappings and projects, restorable for 7 days.
- Settings: server
enabled,port(default 9877),audit_cap(default 5000),trash_ttl_days(default 7).
AI Skills
Install Supbuddy's agent skill at the user level (machine-wide) so the agent sees Supbuddy in every repo without per-project setup. Each global-capable agent has a master on/off plus an autosync toggle (keeps the installed skill refreshed when Supbuddy updates it) and shows its install path + version.
- Who can install at user level: only agents whose global file Supbuddy fully owns and that self-scope (act only when the working directory has a
.supbuddy/): Claude Code (~/.claude/skills/supbuddy/SKILL.md) and Cursor (~/.cursor/skills/supbuddy/SKILL.md). The install is reference-counted under a synthetic__user__ref so it persists independent of any project and is never pruned by the boot reconcile. - Master ↔ project: the AI Skills tab is the master (user-level). To commit a skill into a specific repo, use that project's AI Tools tab and set the target to Project (the old
localscope, which writes into the repo for teammates); User there means the master install covers it. - Agents whose global file holds your own content (Claude
CLAUDE.md, CodexAGENTS.md, Copilot, Windsurf, Continue, JetBrains) are project-level only: a machine-wide write there could clobber your config, so they're injected per-project instead.
Tray menu
The macOS menu bar tray icon opens a menu with:
- Status: …: current proxy state (running / idle).
- DNS Active (:5353): shown when proxy is running.
- LAN Sharing (<ip>): shown when LAN sharing is on.
- Tailscale (<ip>): shown when Tailscale is connected.
- Start Proxy / Stop Proxy: opens the dashboard.
- Projects: each project opens a submenu with Apps (click to open the mapped URL), Supabase services (status dot + open), and Scripts (your bookmarked scripts as a one-click Start <name> / Stop <name> toggle), plus Restart Supabase/Restart services and Show in Supbuddy.
- Open Dashboard.
- Sync AI context for all projects: runs the project-context sync engine for every registered project (writes
.supbuddy/,CLAUDE.md,AGENTS.md, etc.). - Show Logs: reveals
main.login Finder. - Check for Updates...: manual update check (only enabled in packaged builds). The panel names all three moving parts and their versions — the app, the daemon running inside it (
bundledwhen it ships with the app,npmwhen it came from the CLI package), and the CLI itself — because they release on their own cadences and a single unlabelled version number cannot tell you which is behind. A CLI-only release is detected too: the check asks npm for the newestsupbuddyand, when yours is older, says so and gives you the command (npx supbuddy@latest) even though the app itself is current. In that case the panel says "The app is up to date" rather than "You're up to date", which would not be true. A CLI version it cannot determine is shown as unknown rather than left blank, and a failed registry check says it failed instead of implying you are current. - Quit.
File locations
All under ~/Library/Application Support/Supbuddy/ on macOS:
main.log+main.log.1: app logs (rotates at 2 MB).state.json: persistent state (projects, mappings, settings, MCP clients, license).caddy-data/: Caddy's data dir (PKI, autosaves, certs).caddy-data/caddy/pki/authorities/local/root.crt: the local CA cert installed in your Keychain.ca-bundle/current.crt: cumulative PEM containing every Caddy root that has ever been emitted. Used by Bundled-runtime trust as the target forNODE_EXTRA_CA_CERTS/SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Real file (not a symlink) so Bun-bundled CLIs read it correctly.ca-bundle/versioned/<sha>.crt: per-root snapshots for forensics.Caddyfile: generated reverse-proxy config.daemon.json: written while a headless CLI daemon is running (pid, Socket.IO + MCP-HTTP ports, control token);0600, removed on shutdown. Used bysupbuddyCLI commands to discover and authenticate to the daemon, and by the desktop app to detect a running CLI daemon at launch.certs/: legacy CA from the pre-Caddy era (unused in current builds).
MCP-specific:
- MCP client tokens (file-backed secret, mode
0600):~/Library/Application Support/Supbuddy/secrets/mcp-<client-id>.secret - MCP audit log: under
~/Library/Application Support/Supbuddy/, capped ataudit_capentries (default 5000).
Troubleshooting
Run a health & drift scan first (supbuddy doctor)
When something's off, supbuddy doctor is the quickest triage. It runs a read-only scan of 18 checks and prints findings by severity, and many of the issues below have a matching check — an unreadable state.json, an untrusted CA, a wedged Caddy, port 443 not redirecting, stale duplicate CA roots, legacy CA-trust LaunchAgents poisoning public TLS, an agent config still holding a revoked MCP token, a Firefox profile pinning an old Caddy root, and leftovers from deleted projects (Docker containers/volumes, 127.0.0.N loopback aliases, /etc/resolver files, MCP token files). Add --fix to apply the opt-in repairs after a confirmation prompt — some checks are advisory and have no auto-fix. See System doctor for the full check list and flags.
Browser shows "Not secure" or certificate warning
The Caddy CA is not trusted. Open Settings → Network → Install Certificate. macOS will prompt for your password — on macOS 15+ this is the "You are making changes to your Certificate Trust Settings" dialog for the per-user install. After install, fully restart your browser (Cmd+Q, not just close window). Verify: Keychain Access → System keychain, then the login keychain → search for "Caddy Local Authority".
If the install fails with SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible, that is macOS 15+ refusing system-wide trust to a background helper; Supbuddy retries per-user automatically, and if you dismiss that dialog it shows you the no-sudo command to run yourself.
"unable to get local issuer certificate" / "self signed certificate in certificate chain" from Claude Code, Cursor, MCP servers, or other AI tools
These tools ship their own bundled JavaScript runtime (Bun, Electron, pkg-bundled Node) and ignore the system Keychain. Open Settings → General → Bundled-runtime trust and click Install. Then fully quit and relaunch the AI tool; the env var only takes effect for newly-launched processes. Verify with launchctl getenv NODE_EXTRA_CA_CERTS (macOS); it should print ~/Library/Application Support/Supbuddy/ca-bundle/current.crt. If install is refused with a conflict warning, you already have NODE_EXTRA_CA_CERTS pointing at a bundle Supbuddy doesn't own (often a corporate proxy / Zscaler), so Supbuddy won't silently overwrite; use the override prompt or manually concatenate the two PEMs.
If it still fails after a relaunch, the process is probably not using the value launchctl getenv prints. Compare them:
launchctl getenv NODE_EXTRA_CA_CERTS # what Supbuddy set
node -e "console.log(process.env.NODE_EXTRA_CA_CERTS)" # what your shell actually has
If they differ, an app launched before the install captured the old value and is handing it to every shell and dev server it spawns — launchctl setenv cannot change an already-running process. The trust panel lists the divergent value and names the app to relaunch; quitting and reopening that app (not just the terminal tab) fixes it. A value pointing at Caddy's own caddy-data/…/pki/authorities/local/root.crt is the classic case: that file rotates independently of Supbuddy's bundle, so the two agree until they suddenly don't.
"Docker is not running. Please start Docker Desktop."
Compose and Supabase features need Docker. Open Docker Desktop and wait until the whale icon stops animating.
"Docker Compose is not installed"
Compose v2 ships inside Docker Desktop. If you removed Docker Desktop and are using a standalone Docker daemon (e.g. Colima, Rancher), install compose: brew install docker-compose.
"Leftover host containers" / "isolation drift" warning on a project
Supbuddy flags isolation drift when a project's running containers don't match its configured isolation mode, for example a Host project with a stale thin-mode stack still running, or a Thin project with leftover host-mode containers. Switching isolation modes doesn't tear down the old layer, so those containers linger, waste resources, and can shadow the project's real stack. The warning appears in the warnings chip next to the enable toggle (click it to see each item; it shows a spinner while Supbuddy re-checks), as an entry in the issues counter, and as a notice on the Supabase tab listing the exact containers and any data volumes.
Guided cleanup. Open the Supabase tab → Clean up leftovers… to stop and remove the leftover containers. Data volumes are kept by default; deleting them is opt-in, and when the leftover copy looks newer than the active one, it requires an explicit choice and a backup (tarred to …/Supbuddy/backups/<project>-<timestamp>/). If you recently migrated a VM project, any leftover VM container from before migration can also be cleaned up from this flow.
If the leftover copy's data looks newer than the active one, the warning turns red; don't delete its volumes without first deciding which copy to keep. The Configure tab also shows a dismissible note when Supabase stacks are running on your host that Supbuddy doesn't manage at all (e.g. a plain supabase start).
MCP client says "Invalid OAuth error" or "JSON Parse error: Unexpected EOF"
The MCP client is trying OAuth discovery and getting an empty 404. Either the token was lost (regenerate it in Settings → MCP → the client's ⋯ menu → Rotate token) or you're on a build older than the OAuth-probe fix. Update to the latest version; the server now answers OAuth discovery paths with a structured 404 instead of an empty body, and 401 responses include WWW-Authenticate: Bearer so the client doesn't fall back to OAuth.
MCP token disappeared after app restart
Fixed in recent builds. If you're on an older version, regenerate the token. Root cause was that addMcpClient didn't trigger state persistence; the client was held in memory only.
Server Actions return 403 in a Next.js app behind Supbuddy
Next.js's CSRF guard rejects POSTs whose Origin isn't in experimental.serverActions.allowedOrigins. Supbuddy detects this and flags it in the warnings chip: open the Apps tab and hit Fix on the affected app for a paste-ready snippet, or Apply… to preview a unified diff and write the change to next.config directly. After applying, restart your dev server.
On Next.js 15.3+/16, a proxied dev request can also be blocked (e.g. a "Cross origin request detected" warning) because Supbuddy now passes the real browser Origin through rather than rewriting it, and Next validates it against allowedDevOrigins (which defaults to localhost). Add your Supbuddy domain to allowedDevOrigins in next.config — see Next.js cross-origin dev requests. This is a separate key from the Server Actions list; 15.3+/16 may need both.
Vite dev server returns "Blocked request. This host is not allowed." (403)
Vite (v5+) rejects requests whose Host header isn't in server.allowedHosts, so a Vite app reached through a Supbuddy domain 403s until the host is allowed. Supbuddy detects this and flags vite: N hosts blocked in the warnings chip: open the Apps tab and hit Fix on the affected app for a paste-ready snippet, or Apply… to preview a diff and write server.allowedHosts into your vite.config directly. Restart the Vite dev server afterward; Vite does not hot-reload its config. A single .your-project.local entry covers every subdomain.
mail.<project> opens another project's inbox, or Supabase refuses to start over port 54324
Supabase CLI 2.x renamed the mail-catcher section [inbucket] to [local_smtp]. Builds up to 3.6.14 only read [inbucket], so a project whose config.toml says [local_smtp] port = 54624 had its mail port silently read as the stock 54324 — which on a multi-project machine is a different project's inbox. Three symptoms came from that one cause: the generated mail.<project> mapping pointed at 54324, supabase start was refused with "Inbucket needs port 54324 (in use by …)" for a project that never wanted 54324, and Thin's port rewrite skipped the mail keys entirely (managed port key "inbucket.port" not found in config.toml). Current builds read whichever section your file declares. If you are on an older build, either update or rename the section to [inbucket]; after updating, rescan the project so the mapping is regenerated on the right port.
Toggling Supabase analytics said it restarted, and the stack never came back
Fixed in current builds. set_supabase_analytics (and the Supabase tab's analytics toggle) writes the config change and then restarts the stack in the background. Up to 3.6.14 the stop ran first and the start was preflighted only afterwards — so a start that could not succeed left the stack down, while the project card and get_supabase_status went on reporting every service as running from the snapshot taken before the stop.
Two things changed. The restart is now preflighted before anything is stopped, excluding the ports this project's own containers are about to free: if the start could not succeed, the whole operation is refused with the port and the process holding it, and the running stack is left running. And any restart that does fail is recorded on the project — surfaced as supabase_error on get_supabase_status, with the stack's still-"running" services downgraded to unknown, because after a failed restart that is what their state actually is. A later successful restart clears it.
Supabase Realtime: channel reaches SUBSCRIBED but no postgres_changes events arrive
If a channel subscribes fine (and writes succeed) but change events never fire, this is almost always realtime warmup timing right after the stack starts — not the Supbuddy proxy. Local Realtime can accept a channel join and report SUBSCRIBED before its logical-replication binding for the tenant is ready, so INSERT/UPDATEs in that brief window are silently missed. Give the stack a few seconds after the Supabase tab goes green, then re-subscribe (or reconnect the channel). This is unrelated to the .local domain: Kong routes /realtime/v1/* by path and rewrites the upstream Host to its internal realtime tenant, so reaching realtime through https://api.<project>.local behaves identically to the raw localhost:54321 port — forwarding the .local host upstream does not change tenant resolution. The new sb_publishable_* / sb_secret_* API keys also work for local realtime (Kong maps them to the legacy JWT), so you don't need to switch key formats.
A newly registered project's domain doesn't resolve
Fixed in 3.7.8. Resolver files are per project — web.my-app.internal needs /etc/resolver/my-app.internal, its own file — so every new project introduces a suffix nothing on disk covers yet. Registering a project created the project, its mappings and its Caddy routes, and never asked for that file. Enabling, renaming or re-TLD-ing a project all did; registering, the one path that creates the most suffixes, did not.
The result was a project that looked completely healthy — a live mapping, a Caddy site block, no error anywhere — whose domain the OS had no way to resolve. It stayed that way until some unrelated proxy restart happened to audit the machine, which in one reported case was three days. Registering now writes the file as part of the same operation, and if the privileged step is declined or times out the tool says so in a resolver_note on its reply instead of reporting a clean registration.
If a domain still doesn't resolve, get_dns_status and supbuddy doctor now name the specific suffixes the OS has not loaded, rather than reporting one boolean for the whole machine. See below for why that boolean used to read healthy.
get_dns_status said everything was fine and the domain was dark
Fixed in 3.7.8, and worth describing because the failure was invisible by construction.
The "did the OS load /etc/resolver?" signal was a real DNS lookup, but it had two defects that cancelled each other out into a confident "healthy":
- It resolved one domain out of however many you have. On a machine with several projects, an older project whose resolver file was loaded answered on behalf of a newly registered one whose file was missing. One healthy project made the whole machine report healthy.
- It resolved a real managed name, and
getaddrinforeads/etc/hostsbefore it consults any resolver. So the moment Supbuddy's own/etc/hostsfallback engaged, the probe could no longer observe the resolver path at all — and reported success for the fallback's work.
Both are fixed: the check now probes every managed suffix, using a name /etc/hosts cannot answer (it has no wildcards). One suffix that doesn't resolve makes the verdict false and names that suffix, and a lookup that merely times out is still reported as unknown rather than as a fault. The sweep is bounded so status calls stay fast on a machine with many projects.
Supbuddy asked for your password and gave up before you typed it
Fixed in 3.7.8. The DNS resolver step used its own 30-second timeout while every other privileged path in Supbuddy had already moved to a three-minute one. macOS's admin dialog stays open until you answer it, and giving up does not cancel anything — so a password typed at 40 seconds ran the command successfully and delivered the result to a waiter that had already reported failure. The visible symptom was the privileged step did not answer within 30s. Missing: [...] while the prompt was still on screen. All privileged paths now share the same waiter.
Supbuddy wrote the resolver files and macOS ignored them
On some Macs — reported on macOS 26 with System Integrity Protection enabled — the files land correctly and scutil --dns never lists them. Supbuddy asks the OS to re-read them after every write, trying dscacheutil -flushcache, then a launchd restart of the resolver daemon under both its modern and legacy labels, then SIGHUP, then a dynamic-store refresh on the primary network interface. On a SIP-enabled machine the launchd restarts are refused outright (Operation not permitted while System Integrity Protection is engaged), and SIGHUP has not re-read /etc/resolver since Darwin 23.
Until 3.7.8 every one of those attempts discarded its own error output, so this was undetectable from inside the app: the files were correct, nothing had failed, and the domains were dark. Supbuddy now records which reload actually worked and logs the refusal text of the ones that did not, so the daemon log says plainly when no strategy succeeded.
What to do if you hit it. Toggling Wi-Fi off and on forces the rescan — macOS appears to re-read /etc/resolver on a network configuration change rather than on a file write. Do not bother with sudo killall -HUP mDNSResponder; this is the one reload that provably does nothing here, and Supbuddy no longer suggests it. The durable answer is the /etc/hosts fallback, which is built for exactly this machine and engages on the next proxy start.
Project shows a red "PROXY ERROR" banner: domain resolves but won't load
After the proxy starts, Supbuddy runs an end-to-end reachability check: it resolves a project domain through the OS resolver and tries to connect to Caddy on the HTTPS port. If the name resolves but the connection fails, the project shows a red PROXY ERROR banner naming the likely cause (DNS, port-forwarding, or mDNS race) plus a recovery action.
The check waits for the OS to settle, and a single miss no longer raises the banner. Starting the proxy runs the privileged setup, which kickstarts mDNSResponder and rewrites /etc/resolver — for a few seconds afterwards macOS legitimately fails to resolve names it is about to serve normally. Up to and including 3.6.14 the check was a single probe fired 1.5 s after that, so it often measured the settling window rather than the machine: the banner cleared on restart and came back "a few seconds later", then stayed up until the next start even though every URL worked. Current builds re-probe across roughly the first 13 seconds and only report a failure that outlives the whole window; a lookup that fails once is also retried before it counts as "doesn't resolve". Proxy start is not slowed — the check runs in the background.
A banner that no longer applies clears itself. While a reachability fault is showing, Supbuddy re-checks about every 45 seconds and takes the banner down as soon as the name resolves and the port answers again — so a machine that recovers on its own (a resolver reload finishing, Wi-Fi coming back, another device releasing an mDNS name) no longer needs a proxy restart just to stop showing a stale error. It is observation only: no password prompt, no repair, and no elevation. It also only clears the fault it raised — if a port-forwarding failure has since claimed the banner, that one stays up, and clearing still requires Caddy to be serving and the 443 redirect (when you asked for one) to answer.
The most common case: the domain resolves to 127.0.0.1 but port 443 won't connect because the elevated pfctl 443→8443 redirect drifted away (typically after a restart, so Caddy is up on 8443 with nothing forwarding 443). Click Retry; as of v2.3.6 it re-applies the port-forwarding rule (approve the sudo prompt). On older builds, toggle the proxy off→on instead. If LAN sharing is off, disregard any "LAN sharing / Bonjour" wording in the banner; the cause is the missing forward, not mDNS.
If the name doesn't resolve at all, the banner now names the cause it actually measured. Before writing that message Supbuddy probes a name only /etc/resolver can answer, and says one of two different things:
- The resolver path works, and the project is on
.local. macOS reserves.localfor Bonjour/mDNS (RFC 6762), and mDNS is consulted by a path an/etc/resolver/<suffix>file does not govern — so the OS can return "server not found" for a name whose resolver file is present and correct. Retrying rewrites files that were already right and asks for your password to do it, which is why the message names the durable fix instead: move the project off that TLD, in the project dialog (Settings → TLD) or withsupbuddy project set <project> --tld=test. (This is not the.localslowness of 3.5.17 and earlier — that was our own DNS server and it is fixed. This is resolution failing outright, which the suffix genuinely can cause.) - The resolver path is dead — the OS is loading no
/etc/resolverfile at all. Up to 3.6.14 the banner told these users to change their TLD too. That advice is measurably wrong here: on the machine this was diagnosed on,.local,.test,.internaland.devall failed while/etc/hostsworked, so no suffix recovers it. The banner now says the OS is not loading/etc/resolver, that changing TLD will not help, and that Supbuddy has written your domains into/etc/hostsas a fallback (or will on the next proxy start). See the/etc/hostsfallback.
Automatic repairs stop re-prompting. When the proxy is running but unreachable, Supbuddy re-runs the privileged setup to recover it — and that batch always asks for your password. In current builds an automatic attempt (the app re-attaching, a boot auto-start, the owner-ready repair) is skipped if the same failure was already re-applied within the last 10 minutes and didn't recover; the daemon logs one line and leaves the banner and its diagnosis standing. A repeated prompt that fixes nothing only teaches you to dismiss prompts. Anything you initiate — Retry in the app, supbuddy proxy restart, the MCP start_proxy — is never throttled, and a recovery, or a different fault, clears the cooldown immediately.
Port forwarding is on but 443 won't connect
Supbuddy reports port forwarding as active only when a live probe confirms 443 actually reaches Caddy — the rule being on disk isn't enough. If the rule is present but not being enforced (typically right after a reboot, or when an older Supbuddy version left /etc/pf.conf in a broken state), the status carries a pf_not_enforcing diagnostic instead of a false "enabled", and the banner tells you to restart the proxy to re-apply the redirect.
If you see "not enforcing" or repeated password prompts with no mappings enabled, you are on a build older than 3.6.12. The 443 probe was gated on the Caddy process being alive rather than on something actually listening, so a project with no enabled mappings — whose Caddyfile has no site blocks, leaving nothing bound to 8443 — made a correctly-loaded pf rule look dead: NOT ENFORCING, the red banner, and a repair prompt on every network change. Current builds report that machine as unknown and stay quiet. To confirm your ruleset is fine: sudo pfctl -a 'virtual.localhost' -s nat lists both rdr rules.
Older versions appended their rdr-anchor to the end of /etc/pf.conf, after Apple's filter anchor — which pf rejects, because translation rules must come before filtering rules. That silently invalidated the whole ruleset, so every later pfctl -f failed and 443 was dead. Current builds insert the anchor in the correct translation section and self-heal a file corrupted by the old version on the next proxy start. Supbuddy keeps a single stable backup at /etc/pf.conf.supbuddy-backup (older builds accumulated unbounded timestamped backups). If a restart doesn't fix it, inspect /etc/pf.conf and confirm the rdr-anchor "virtual.localhost" line sits before anchor "com.apple/*".
Proxy came up but shows a degraded "error" state
If the one-time sudo prompt for port forwarding / DNS is cancelled or fails, Supbuddy no longer aborts the whole start. Caddy still starts and HTTPS keeps working on the high port (8443), and the CA is still generated; the proxy just shows an actionable error (degraded) state with a Retry. Click Retry and approve the sudo prompt to restore real-port (80/443) access and DNS. Until then, reach your apps on https://<domain>:8443.
Port already in use (8080, 8443, 5353, 9877)
Default ports: HTTP 8080, HTTPS 8443, DNS 5353, MCP 9877. Change them in Settings → Network / Settings → MCP. Find what's holding a port: lsof -i :<port>.
Wipe everything and start over
Use supbuddy reset (see System reset) — it backs up anything you can't regenerate first, and it removes the things a plain rm -rf leaves behind (the pf redirect, the resolver files, the loopback aliases, the trusted CA):
supbuddy reset --tier=soft # just the app state and caches
supbuddy reset --tier=deep # + services, Caddy leftovers, /etc integrations, CA trust
supbuddy reset --tier=full # + project data, repo artifacts, secrets, service, app data
The manual equivalent, if the CLI isn't available — quit Supbuddy first, and note that this deletes secrets/ and any backups under it with no copy anywhere:
# Wipe app data (state, certs, Caddyfile, logs, MCP tokens under secrets/)
rm -rf ~/Library/Application\ Support/Supbuddy
# Optional: remove the trusted CA
sudo security delete-certificate -c "Caddy Local Authority" /Library/Keychains/System.keychain
FAQ
Is Supbuddy free?
Yes. Supbuddy is free. Register as many projects and mappings as you want, with full HTTPS, full DNS, full Supabase isolation, and full read and write MCP access. There are no caps and no tiers.
Does Supbuddy send my data anywhere?
No. Caddy, the DNS server, and the MCP server all run locally on your Mac. The only outbound traffic is: Tailscale split-DNS push (only if you enabled it), auto-update checks (GitHub Releases), and Google Analytics on the marketing site (not the desktop app). The desktop app does not send telemetry.
Can I work offline?
Yes. The app works fully offline once the CA is trusted and projects are registered.
Linux / Windows support?
The desktop app is macOS-only in v2. The headless CLI and daemon also run on Linux, where supbuddy service install registers a systemd-user start-on-login unit (macOS uses launchd). Windows is not supported. A few desktop code paths (certutil, update-ca-certificates) anticipate other platforms but are not tested there.
Can I use my own TLD?
Yes. Set any TLD in Settings → General → Default TLD. Supbuddy installs /etc/resolver/<project-domain> files that tell macOS to query our DNS server for that project's domain. Avoid TLDs that actually resolve on the public internet (.com, .net, etc.); your browser will hit the real site for cached entries.
What happens if I delete a project?
The project moves to the Trash (visible in Settings → MCP → Trash) for 7 days, then is permanently deleted by the sweep timer. Restoring brings back the project record and all its mappings.
How do I uninstall Supbuddy?
- Quit the app (the full reset refuses to run while it's open, because its watchdog restarts the daemon).
- Run
supbuddy reset --tier=fulland typeRESETwhen it asks. This backs up your project data, then removes the containers, volumes,/etcintegrations, CA trust, repo artifacts, credentials, the start-on-login service and the app-data directory — keeping<app-data>/backups/reset-<timestamp>/. See System reset, including the short list of things it deliberately leaves behind. - Drag Supbuddy.app from
/Applicationsto the Trash, and move the backups directory somewhere safe (or delete it). - If you'd rather not use the CLI: see "Wipe everything and start over" above for the manual equivalent, plus
sudo security delete-certificate -c "Caddy Local Authority" /Library/Keychains/System.keychainto remove the trusted CA.
Where do I report a bug?
Email support with your version (visible at the bottom of the Settings popover) and the relevant lines from ~/Library/Application Support/Supbuddy/main.log.