This file provides guidance to AI agents when working with code in this repository.
php artisan boost:add-skill tonysm/sail-proxy
Save this content to: AGENTS.md
# AGENTS.md
This file provides guidance to AI agents when working with code in this repository.
## What this is
A Laravel Zero CLI, distributed as a PHAR, that puts local Laravel Sail apps behind
[kamal-proxy](https://github.com/basecamp/kamal-proxy) so each one is served on its own
`*.localhost` hostname. `README.md` documents the user-facing commands and every
`SAIL_PROXY_*` environment variable; `CONTRIBUTING.md` documents the development
workflow in depth and is worth reading before changing anything non-trivial.
## Commands
```bash
composer install # every runtime dep is in require-dev; --no-dev cannot boot
php sail-proxy <command> # run the app from source
vendor/bin/pest # tests
vendor/bin/pest tests/Feature/ConfigCommandTest.php # one file
vendor/bin/pest --filter='part of the test name' # one test
vendor/bin/pint # format
vendor/bin/pint --test # check formatting (CI runs this)
```
CI runs Pest on PHP 8.3, 8.4 and 8.5.
## Architecture
Nine commands in `app/Commands/`, each driving thin wrappers in `app/Support/`:
- `Docker` — the `docker` CLI (containers, networks, volumes, labels). `labels()` reads a
set of labels in one `inspect`, and returns null when the container is gone.
- `DockerCompose` — `docker compose` for a single project directory, bound as a singleton
to `getcwd()`. Prefers `./vendor/bin/sail` over bare `docker compose` when present.
- `KamalProxy` — `kamal-proxy deploy|remove|list`, run via `docker exec` into the proxy
container. `services()` parses the `list` table; `apps()` is the service column of it.
- `OverrideFile` — renders the `compose.override.yaml` that `config` writes.
- `EnvFile` — finds and rewrites `.env` lines that point at a service by its short name.
`install` creates the shared network and starts the `sail-proxy` (kamal-proxy) container;
nothing is pinned, so Docker assigns the network a subnet and the proxy an address from its
own pool. `config` rewrites a project's compose to join that network — under a network alias
matching its hostname — and registers it; `register`/`unregister` talk to kamal-proxy
directly; `uninstall` reverses `install`. `start`/`stop`/`restart` are lifecycle verbs over
the container `install` created and touch nothing else -- `restart` is `stop --brief` then
`start`, via `$this->call()`.
Container-to-container resolution is Docker's embedded resolver answering that alias; there
is no DNS container. Host-to-container resolution is the OS sending `*.localhost` to
`127.0.0.1`, where the proxy publishes port 80.
Each app gets **two** aliases -- `myapp.localhost` and `myapp.internal` -- because libcurl will
not resolve the first from inside a container. See the invariant below and the README's
"Container-to-container requests".
Everything configurable lives in `config/proxy.php`, each key backed by a `SAIL_PROXY_*`
env var read from the real environment (not a `.env`).
## Invariants
- **Always pass argv to `Process` as an array**, never a string, so container names and
hostnames can't shell-inject.
- **The shared network must not be named `sail`** — Sail's own compose file defines a
project-local network by that name, and overriding it drags mysql/redis/mailpit onto the
shared network where projects collide on aliases.
- **`config` inspects the project with `-f <base compose file>`**, never the merged config,
or a re-run feeds our previous override back in and accumulates networks.
- **Restart projects through `./vendor/bin/sail`** (`DockerCompose::command()`): it exports
`WWWUSER`/`WWWGROUP`, without which the container runs as the wrong UID.
- **An explicit `docker stop` outranks `--restart unless-stopped`.** The proxy stays down
across daemon and machine restarts until something starts it again, which is why `start`
exists as its own verb -- and why `stop` says so in its output.
- **`docker rm -f` exits 0 for a missing container**, so check existence separately before
reporting a removal.
- **`config` has to settle the hostname before it writes the override**, because the
hostname goes in as a network alias. Prompting for it at registration time — after the
file is written — would be too late.
- **Routes are settled before the override too**, for the same reason: a routed service's
network and aliases go in the file.
- **Attaching a service to the shared network exposes its short name.** Compose always adds
the service name as an alias, so two projects serving `mailpit` both answer to it there, and
an app on that network can silently get the other project's. This is why only services the
user routes are attached, and why `config` offers to move `.env` onto the `.internal` name.
- **`kamal-proxy deploy --force` does not skip health checks**, whatever its help says. It only
stops waiting: a target that never passes a check serves 503s while `list` reports `running`.
Use `--health-check-path` or `--health-check-port` for a target with no `/up`.
- **`kamal-proxy deploy` must not run under `Process`'s 60s default timeout.** It enforces
`--deploy-timeout` itself, 120s by default, so a slow deploy would otherwise throw.
- **Aliases come in pairs, and the `.localhost` one is inert for libcurl.** libcurl resolves
any `.localhost` name to `127.0.0.1` itself, so from inside a container that alias is never
reached -- the request loops back to the caller. `ConfigCommand::containerHostname()` adds a
second alias on `proxy.container_tld` for real container-to-container traffic. It keys off
the literal `.localhost` suffix rather than `proxy.tld`, since that is what libcurl
special-cases: a user running `SAIL_PROXY_TLD=test` already has a working name and gets no
redundant second alias.
- **kamal-proxy colours its `list` output unconditionally.** `NO_COLOR`, `TERM=dumb` and
the absence of a TTY make no difference, so anything reading that table has to strip
`\e[...m` first — `KamalProxy::services()` does, and splits cells on two-or-more spaces
since that is the padding kamal-proxy writes.
- **`docker inspect --format` label reads are positional.** `Docker::labels()` asks for
several labels separated by `{{"\n"}}` and maps the lines back onto the names it was
given, so only the *trailing* newline may be trimmed: an unset first label comes back as
a leading blank line, and trimming that shifts every value up one.
- **Aliases force the map form of `networks:`.** A plain list cannot carry them, so
`OverrideFile` switches to `network: {aliases: [...]}` and has to bring the service's
other networks along as keys with a null value. It keeps the list form when there is no
alias to add.
## Testing
Tests fake the process layer; nothing touches Docker. Use the helpers in `tests/Pest.php`
rather than `Process::fake()` directly — `Process::fake()`'s string patterns don't match our
argv arrays:
```php
fakeProcesses(['docker ps*' => Process::result('sail-proxy')]);
assertRanProcess('docker exec sail-proxy kamal-proxy remove myapp');
assertDidntRunProcess('docker network create*');
```
`Process::recorded()` does not exist in this version of `illuminate/process`; to assert
ordering, record commands inside a `Process::fake(closure)` (see `UninstallCommandTest`).
Two `expectsOutputToContain()` calls matching the same *write* will fail — a table or a
blob of JSON is one write, so only the first substring ever matches. Assert the whole line
as one substring, or run the command with `Artisan::call()` and assert against
`Artisan::output()` (see `StatusCommandTest`).
## Distribution
Composer serves the committed `builds/sail-proxy` archive (`bin` points at it, and `/builds`
is deliberately not gitignored). The same archive is the GitHub release asset installed by
the curl one-liner and by `mise use -g github:tonysm/sail-proxy` — its name is load-bearing:
keep it bare and unversioned (`sail-proxy`), or both break.
Releasing is one command; it validates, runs pint and pest, rebuilds the archive with the
tag baked in, commits it (Composer installs straight from the tag, so the build commit must
land first), then tags and pushes main and the tag in one `--atomic` push:
```bash
scripts/release.sh 1.2.3 # or 1.2.3-rc.1 for a prerelease, or --dry-run
```
Tags are bare semver (`1.2.3`); the release workflow triggers on numeric semver tags. It
re-runs the checks, fails if the version baked into the archive doesn't match the tag, and
publishes the archive plus `checksums.txt` with a build-provenance attestation.
`-`-suffixed tags ship as prereleases and never become Latest.
This file provides guidance to AI agents when working with code in this repository.
A Laravel Zero CLI, distributed as a PHAR, that puts local Laravel Sail apps behind
kamal-proxy so each one is served on its own
*.localhost hostname. README.md documents the user-facing commands and every
SAIL_PROXY_* environment variable; CONTRIBUTING.md documents the development
workflow in depth and is worth reading before changing anything non-trivial.
composer install # every runtime dep is in require-dev; --no-dev cannot boot
php sail-proxy <command> # run the app from source
vendor/bin/pest # tests
vendor/bin/pest tests/Feature/ConfigCommandTest.php # one file
vendor/bin/pest --filter='part of the test name' # one test
vendor/bin/pint # format
vendor/bin/pint --test # check formatting (CI runs this)
CI runs Pest on PHP 8.3, 8.4 and 8.5.
Nine commands in app/Commands/, each driving thin wrappers in app/Support/:
Docker — the docker CLI (containers, networks, volumes, labels). labels() reads a
set of labels in one inspect, and returns null when the container is gone.DockerCompose — docker compose for a single project directory, bound as a singleton
to getcwd(). Prefers ./vendor/bin/sail over bare docker compose when present.KamalProxy — kamal-proxy deploy|remove|list, run via docker exec into the proxy
container. services() parses the list table; apps() is the service column of it.OverrideFile — renders the compose.override.yaml that config writes.EnvFile — finds and rewrites .env lines that point at a service by its short name.install creates the shared network and starts the sail-proxy (kamal-proxy) container;
nothing is pinned, so Docker assigns the network a subnet and the proxy an address from its
own pool. config rewrites a project's compose to join that network — under a network alias
matching its hostname — and registers it; register/unregister talk to kamal-proxy
directly; uninstall reverses install. start/stop/restart are lifecycle verbs over
the container install created and touch nothing else -- restart is stop --brief then
start, via $this->call().
Container-to-container resolution is Docker's embedded resolver answering that alias; there
is no DNS container. Host-to-container resolution is the OS sending *.localhost to
127.0.0.1, where the proxy publishes port 80.
Each app gets two aliases -- myapp.localhost and myapp.internal -- because libcurl will
not resolve the first from inside a container. See the invariant below and the README's
"Container-to-container requests".
Everything configurable lives in config/proxy.php, each key backed by a SAIL_PROXY_*
env var read from the real environment (not a .env).
Process as an array, never a string, so container names and
hostnames can't shell-inject.sail — Sail's own compose file defines a
project-local network by that name, and overriding it drags mysql/redis/mailpit onto the
shared network where projects collide on aliases.config inspects the project with -f <base compose file>, never the merged config,
or a re-run feeds our previous override back in and accumulates networks../vendor/bin/sail (DockerCompose::command()): it exports
WWWUSER/WWWGROUP, without which the container runs as the wrong UID.docker stop outranks --restart unless-stopped. The proxy stays down
across daemon and machine restarts until something starts it again, which is why start
exists as its own verb -- and why stop says so in its output.docker rm -f exits 0 for a missing container, so check existence separately before
reporting a removal.config has to settle the hostname before it writes the override, because the
hostname goes in as a network alias. Prompting for it at registration time — after the
file is written — would be too late.mailpit both answer to it there, and
an app on that network can silently get the other project's. This is why only services the
user routes are attached, and why config offers to move .env onto the .internal name.kamal-proxy deploy --force does not skip health checks, whatever its help says. It only
stops waiting: a target that never passes a check serves 503s while list reports running.
Use --health-check-path or --health-check-port for a target with no /up.kamal-proxy deploy must not run under Process's 60s default timeout. It enforces
--deploy-timeout itself, 120s by default, so a slow deploy would otherwise throw..localhost one is inert for libcurl. libcurl resolves
any .localhost name to 127.0.0.1 itself, so from inside a container that alias is never
reached -- the request loops back to the caller. ConfigCommand::containerHostname() adds a
second alias on proxy.container_tld for real container-to-container traffic. It keys off
the literal .localhost suffix rather than proxy.tld, since that is what libcurl
special-cases: a user running SAIL_PROXY_TLD=test already has a working name and gets no
redundant second alias.list output unconditionally. NO_COLOR, TERM=dumb and
the absence of a TTY make no difference, so anything reading that table has to strip
\e[...m first — KamalProxy::services() does, and splits cells on two-or-more spaces
since that is the padding kamal-proxy writes.docker inspect --format label reads are positional. Docker::labels() asks for
several labels separated by {{"\n"}} and maps the lines back onto the names it was
given, so only the trailing newline may be trimmed: an unset first label comes back as
a leading blank line, and trimming that shifts every value up one.networks:. A plain list cannot carry them, so
OverrideFile switches to network: {aliases: [...]} and has to bring the service's
other networks along as keys with a null value. It keeps the list form when there is no
alias to add.Tests fake the process layer; nothing touches Docker. Use the helpers in tests/Pest.php
rather than Process::fake() directly — Process::fake()'s string patterns don't match our
argv arrays:
fakeProcesses(['docker ps*' => Process::result('sail-proxy')]);
assertRanProcess('docker exec sail-proxy kamal-proxy remove myapp');
assertDidntRunProcess('docker network create*');
Process::recorded() does not exist in this version of illuminate/process; to assert
ordering, record commands inside a Process::fake(closure) (see UninstallCommandTest).
Two expectsOutputToContain() calls matching the same write will fail — a table or a
blob of JSON is one write, so only the first substring ever matches. Assert the whole line
as one substring, or run the command with Artisan::call() and assert against
Artisan::output() (see StatusCommandTest).
Composer serves the committed builds/sail-proxy archive (bin points at it, and /builds
is deliberately not gitignored). The same archive is the GitHub release asset installed by
the curl one-liner and by mise use -g github:tonysm/sail-proxy — its name is load-bearing:
keep it bare and unversioned (sail-proxy), or both break.
Releasing is one command; it validates, runs pint and pest, rebuilds the archive with the
tag baked in, commits it (Composer installs straight from the tag, so the build commit must
land first), then tags and pushes main and the tag in one --atomic push:
scripts/release.sh 1.2.3 # or 1.2.3-rc.1 for a prerelease, or --dry-run
Tags are bare semver (1.2.3); the release workflow triggers on numeric semver tags. It
re-runs the checks, fails if the version baked into the archive doesn't match the tag, and
publishes the archive plus checksums.txt with a build-provenance attestation.
--suffixed tags ship as prereleases and never become Latest.
How can I help you explore Laravel packages today?