Framework wiring (splash doctor)¶
Allocating a port doesn't always reach the running process. Most frameworks hardcode the port in one or two config files that override the env var, so splashdown carries per-framework wiring checks that detect those hardcoded points and (where safe) auto-patch them. splash init runs the configuration checks after scaffolding, checks the result again, and lists the files it changed, so a normal first setup does not need splash doctor --fix for those. The one exception is the hook row below. Installing a local hook is activation rather than configuration, so init never runs that check and splash trust is what puts the hook in place. splash doctor re-runs every check anytime to verify, and splash doctor --fix re-applies the autofixes.
Checks that patch a consumer of generated values read the environment file this checkout writes, which is splashdown.env unless splash init --env-file PATH chose another destination. Wiring that already reads that file is left exactly as you wrote it. Wiring that names a different file is reported as a problem with the edit to make, not quietly rewritten and not passed as wired.
| Profile | Check | What it ensures |
|---|---|---|
| react-native | hook |
post-checkout forwards Git's event to Splashdown through your existing hook manager instead of clobbering core.hooksPath |
| react-native | rn-metro-config |
metro.config.js consumes RCT_METRO_PORT. Auto-patches the recognized port: <N> literal shape, otherwise prints the exact snippet to paste |
| react-native | rn-pkg-port |
package.json start/ios/android scripts don't carry --port <N> (which would override the env var), auto-stripped |
| react-native | rn-xcode-env |
ios/.xcode.env exports a splashdown-managed RCT_METRO_PORT block that reads your configured environment file. iOS bakes the port into the binary at compile time, so Xcode-GUI builds need this to pick up the per-checkout port. Only a line that assigns or exports RCT_METRO_PORT counts, so a dotenv path mentioned for any other reason, such as export ENVFILE=.env.staging, is left alone and does not stop the fix. Any spelling that resolves to the configured file counts, including a hand-written conditional, and a reference through a shell variable splashdown does not recognize is reported rather than accepted |
| react-native, expo | watchman-root |
When Watchman is installed, checks whether it watches an ancestor of the checkout. Runs at the checkout root, including when the app lives in a subdirectory. Report-only |
| vite | vite-config-process-env |
vite.config.{ts,js} reads env vars from process.env rather than loadEnv(). Auto-rewrites env.X to process.env.X so the environment file loaded by mise/direnv/devbox reaches Vite. Skipped when Vite already loads your configured destination itself, which needs a file Vite loads in development mode (.env, .env.local, .env.development, .env.development.local) directly in the Vite root and a loadEnv(mode, dir, "") call whose dir is that root, because the config then reads the real values without any rewrite. A name you already read as process.env.X elsewhere is left alone, so the process.env.X \|\| env.X fallback chain survives |
| vite | vite-port-wired |
vite.config.{ts,js} names WEB_DEV_PORT somewhere, otherwise the allocated port is never consumed. Manual-only (adding a server.port block to an arbitrary config is not safe to automate). Any spelling counts, including process.env["WEB_DEV_PORT"] and destructuring |
| astro | astro-config-port |
astro.config.* sets server.port from WEB_DEV_PORT. Astro never reads PORT from the environment, so an unwired config always boots on 4321. Auto-injects the line into a defineConfig({...}) or bare object export. If a server: block already exists it prints the snippet instead, because that block may be nested under vite: where the port configures Vite rather than Astro |
| (any) | compose-hardcoded-ports |
compose.yaml / docker-compose.yml uses ${VAR:-default} for host ports and no literal container_name. Reads both YAML layouts, so ports: ['5432:5432'] and db: { container_name: x } are caught alongside the block-style forms. Long syntax counts too, where published: is the host port. A ports: entry it cannot classify (a YAML alias, say) is reported as a problem rather than passed, so a green result always means the file was actually read. Manual-only (splashdown ships no YAML parser, and rewriting indentation-sensitive YAML by regex is not safe). Runs at the repo root whatever framework was detected, since compose is infrastructure rather than an app |
| springboot | springboot-application-properties |
Every config Spring may load uses the server.port=${PORT:8080} placeholder. Reads YAML's nested server: / port: form as well as the flat properties spelling, and scans profile-specific files (application-dev.properties), since one of those pinning a literal overrides a wired base file. The nested lookup only matches a top-level server:, so Actuator's management.server.port is not mistaken for the app's, and the last declaration in a file wins, matching Spring. Manual-only (Java config rewrites are too risky to automate) |
| laravel | vite-port-wired |
Laravel runs two dev servers that both collide across worktrees: php artisan serve (reads SERVER_PORT straight from the environment, nothing to patch) and Vite for assets. The check covers the Vite half, so vite.config.js must name WEB_DEV_PORT. An API-only Laravel app with no vite config reports green env-only |
| angular | angular-pkg-port |
package.json scripts running ng serve pass --port $WEB_DEV_PORT. Angular reads no env var for its port at all, and writing a literal into the committed angular.json would churn it in every worktree, so the value goes through the npm script, which npm runs via a shell. The variable has to reach the flag: WEB_DEV_PORT=4200 ng serve only sets an environment variable, which ng serve never reads. A project with no ng serve script at all is reported as a problem rather than a pass, since there is then nowhere for the allocated port to go. Auto-patched, replacing any port flag already present rather than appending a second one, and placing it on ng serve itself so a compound script does not hand it to whatever follows the &&. Wires npm start, a bare ng serve still uses angular.json's default |
| deno | deno-port-wired |
Something consumes PORT, either a deno.json task passing --port $PORT or server code reading Deno.env.get("PORT"). Deno has no port env var of its own: deno serve and Deno.serve() both bind 8000 regardless. Auto-patches deno serve tasks, inserting the flag before the script argument (anything after it goes to the script, not to Deno), and relocates a flag that is already there in the wrong place. The rewrite is scoped to Deno's own flags, so a chained sidecar command keeps its own --port. The check applies the same position rule, so an appended --port $PORT is reported rather than accepted, and it wants splashdown's own PORT rather than any variable whose name happens to contain it. The in-code form is reported with the snippet to paste. deno.jsonc is never rewritten, since a JSON round-trip would drop its comments |
| aspnetcore | aspnet-launch-settings |
No "commandName": "Project" profile in Properties/launchSettings.json pins applicationUrl. dotnet run reads that key and it wins over an inherited ASPNETCORE_HTTP_PORTS, so the allocated port is silently ignored while it is set. Auto-drops the key (launchSettings is JSON, so the rewrite is safe). IISExpress profiles are left alone, since only IIS Express reads theirs. Requires .NET 8+: ASPNETCORE_HTTP_PORTS does not exist on net6.0/net7.0, so on those target frameworks the check turns report-only and never strips applicationUrl, because removing it there would strand the app on the shared default 5000 |
A ✓ confirms the named check. File checks strip comments before looking, so a commented-out // server: { port: process.env.WEB_DEV_PORT } left over from an earlier attempt does not count as wiring. Where a check cannot make sense of a config at all, it reports a ✗ saying so rather than passing it. A check that raises costs one ✗ and the rest of the run continues.
Shared Watchman roots. The watchman-root check queries the existing daemon with a
three-second timeout. If it reports an ancestor watch, review which projects use that watch
before removing it with watchman watch-del PATH, then restart Metro from this checkout.
doctor --fix leaves watches untouched. Missing Watchman skips the check, while query failures
and invalid responses leave roots unverified and produce a ✗. A ✓ only confirms that no ancestor
watch was found. It does not verify Metro or hot reload.
Hook-manager coexistence. splash integrates automatically with Husky, Lefthook, pre-commit, prek, and simple-git-hooks. It adds or updates only its own entry, so your other hook jobs and scripts stay exactly as they are. With no manager it installs an untracked hook in Git's common hooks directory, which is shared by the repository's worktrees.
To decide who owns the event, splash looks first at core.hooksPath, then at the hooks already installed in the directory Git actually runs, then at the manager configuration files in your checkout, and last at what package.json declares. It never changes core.hooksPath, and it never installs a hook manager for you. Overcommit, an unrecognized core.hooksPath, and a checkout that configures two managers with equal weight are all left untouched. In those cases splash prints the exact entry to add yourself, written in that manager's own syntax.
splash doctor # read-only report (✓/✗ per check)
splash doctor --fix # apply autofixes; print manual instructions for the rest
splash doctor --framework=react-native # override detection if needed
--framework accepts registered profile names shown by splash doctor --help. An unknown name is
a usage error rather than a successful run with no checks.
Framework and hook-manager autofixes edit only regular files inside the checkout. A symlinked destination, a symlinked parent directory, or another non-regular entry is rejected. The native Git hook gets the same final-file protection in Git's resolved hooks directory. Existing modes are preserved and accepted changes replace the directory entry atomically, so a link is never followed.
Known limitation: RN Android. Android's Metro port is also baked into the build (via the RN Gradle plugin / BuildConfig), with a different mechanism than iOS. Splashdown doesn't currently wire the Android side. For now yarn android works (the RN CLI propagates RCT_METRO_PORT to Gradle), but bare gradle assembleDebug may default to 8081. Tracked as a future check.