From 2bf0765a983f5a2269bd5b5b0f4533aa3624ef5a Mon Sep 17 00:00:00 2001 From: suiramdev Date: Wed, 23 Sep 2026 13:57:04 +0200 Subject: [PATCH] chore(dev): route the dev stack through a shared Traefik instead of OrbStack The dev stack reached the browser through OrbStack's proxy, which Docker Desktop does not have. Services now carry Traefik labels and join the external devproxy network; scripts/dev.ts reuses a running proxy on that network or starts docker-compose.proxy.yml. Hosts become http://..freenary.localhost. docker-compose.yml is unchanged. Signed-off-by: suiramdev --- .env.example | 12 +-- .github/CONTRIBUTING.md | 6 +- .github/workflows/ci.yml | 1 + AGENTS.md | 4 +- README.md | 2 +- apps/fumadocs/AGENTS.md | 2 +- .../content/docs/next/contributing/index.mdx | 9 +- .../docs/next/contributing/local-stack.mdx | 60 ++++++++---- .../docs/next/contributing/writing-docs.mdx | 2 +- .../docs/next/self-hosting/configuration.mdx | 8 +- docker-compose.dev.yml | 56 ++++++++--- docker-compose.mail.yml | 14 ++- docker-compose.proxy.yml | 41 ++++++++ docs/engineering/platform.md | 11 ++- packages/email/AGENTS.md | 2 +- scripts/dev-identity.test.ts | 24 ++--- scripts/dev-identity.ts | 23 ++--- scripts/dev.ts | 98 +++++++++++++++++++ scripts/env-sync.ts | 21 ++-- 19 files changed, 297 insertions(+), 99 deletions(-) create mode 100644 docker-compose.proxy.yml diff --git a/.env.example b/.env.example index c944a33..2af77b0 100644 --- a/.env.example +++ b/.env.example @@ -173,10 +173,10 @@ FREENARY_VERSION=latest # --- Development only --- # The API server hostname. It fills `BETTER_AUTH_URL`. -# SERVER_HOST=server.freenary.orb.local +# SERVER_HOST=server.freenary.localhost # The web app hostname. It fills `CORS_ORIGIN`. -# WEB_HOST=web.freenary.orb.local -# The documentation hostname, in the OrbStack label alone. -# DOCS_HOST=docs.freenary.orb.local -# The Mailpit inbox hostname, in the OrbStack label alone. It applies to `bun run dev:mail`. -# MAIL_HOST=mail.freenary.orb.local +# WEB_HOST=web.freenary.localhost +# The documentation hostname, in the Traefik route alone. +# DOCS_HOST=docs.freenary.localhost +# The Mailpit inbox hostname, in the Traefik route alone. It applies to `bun run dev:mail`. +# MAIL_HOST=mail.freenary.localhost diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0a66acf..606c8d5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -11,14 +11,14 @@ Thanks for contributing to Freenary. ## Local Setup -Containerised stack (needs [OrbStack](https://orbstack.dev), applies the migrations for you): +Containerised stack (needs Docker and a free port 80, applies the migrations for you): ```bash bun install bun run dev:up # PostgreSQL, migrations, API server, web app, docs site ``` -Local stack, without OrbStack: +Local stack, with the applications on your host: ```bash bun install @@ -56,7 +56,7 @@ bun run build # every app Most lint/format issues are auto-fixable with `bun run fix`. -CI runs one more job that no root script covers: it parses both Compose files with `docker compose config --quiet`, and it asserts that `docker-compose.yml` still refuses an empty `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. Reproduce it before you change either file. +CI runs one more job that no root script covers: it parses every Compose file with `docker compose config --quiet`, and it asserts that `docker-compose.yml` still refuses an empty `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. Reproduce it before you change either file. CI runs no tests, and there is no root `test` script. Run the test files your change touches by hand with `bun test `. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b137284..4cacaa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: run: | docker compose -f docker-compose.dev.yml config --quiet docker compose -f docker-compose.dev.yml -f docker-compose.mail.yml config --quiet + docker compose -f docker-compose.proxy.yml config --quiet printf 'POSTGRES_PASSWORD=x\nBETTER_AUTH_SECRET=y\n' > .env docker compose -f docker-compose.yml config --quiet : > .env diff --git a/AGENTS.md b/AGENTS.md index 465efa1..ca3a4ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ bun install bun run dev:up # PostgreSQL, migrations, API server, web app, docs site ``` -`dev:up` runs `docker-compose.dev.yml` under a per-worktree Compose project and prints the URLs it serves. No dev service publishes a host port: the stack reaches you through OrbStack hostnames (`web..freenary.orb.local`, `server..freenary.orb.local`, `docs..freenary.orb.local`), and the slug comes from the git branch. Without OrbStack, use the local path instead: +`dev:up` runs `docker-compose.dev.yml` under a per-worktree Compose project and prints the URLs it serves. No dev service publishes a host port: one shared Traefik on port 80 routes plain-HTTP hostnames (`web..freenary.localhost`, `server..freenary.localhost`, `docs..freenary.localhost`) to the containers, and the slug comes from the git branch. `scripts/dev.ts` reuses a Traefik already on the `devproxy` network, or starts `docker-compose.proxy.yml`; the self-hosting `docker-compose.yml` never involves it. Use Chrome or Firefox, which treat `*.localhost` as a secure context. Without Docker for the apps, use the local path instead: ```bash bun run db:start # PostgreSQL container from docker-compose.yml (needs the root .env: cp .env.example .env) @@ -21,7 +21,7 @@ bun run db:push # apply the Prisma schema bun run dev # web 3001, server 3000, docs 4000 ``` -The dev stack needs no `.env`: the `server` service declares `env_file` with `required: false`, so an optional root `.env` reaches the container line by line, and `environment` carries only the six values the stack derives from the worktree — `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN`, `AUTH_COOKIE_DOMAIN` and `NODE_ENV` (Compose gives `environment` precedence over `env_file`). It configures no email provider, so the one-time-code flows are off. `bun run dev:mail` layers `docker-compose.mail.yml` over it: that file starts `axllent/mailpit:v1.28` and points the server at it with `EMAIL_PROVIDER=smtp`, `SMTP_HOST=mailpit`, `SMTP_PORT=1025` and `EMAIL_FROM=Freenary `, and the inbox is a web page at `https://mail..freenary.orb.local`. `bun run dev:reset` destroys the volumes and starts over. The `bootstrap` service runs `prisma migrate deploy` before the API server starts. +The dev stack needs no `.env`: the `server` service declares `env_file` with `required: false`, so an optional root `.env` reaches the container line by line, and `environment` carries only the six values the stack derives from the worktree — `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `CORS_ORIGIN`, `AUTH_COOKIE_DOMAIN` and `NODE_ENV` (Compose gives `environment` precedence over `env_file`). It configures no email provider, so the one-time-code flows are off. `bun run dev:mail` layers `docker-compose.mail.yml` over it: that file starts `axllent/mailpit:v1.28` and points the server at it with `EMAIL_PROVIDER=smtp`, `SMTP_HOST=freenary-mailpit`, `SMTP_PORT=1025` and `EMAIL_FROM=Freenary `, and the inbox is a web page at `http://mail..freenary.localhost`. `bun run dev:reset` destroys the volumes and starts over. The `bootstrap` service runs `prisma migrate deploy` before the API server starts. **There is no seed script.** Create an account through the sign-in screen. Neither `bun run dev:up` nor `bun run dev` configures an email provider, so `requireEmailVerification` stays off, sign-up returns a session at once, and the interface shows no **Forgot password?** link. To work on the one-time-code flows, start the stack with `bun run dev:mail` and read the code in the Mailpit inbox. Bank data needs real bank-provider credentials (`BANKING_PROVIDER`, `POWENS_*` or `ENABLE_BANKING_*`); with none, the bank list reports that bank linking is unavailable and onboarding skips the connect step. diff --git a/README.md b/README.md index 96da13d..3d89bbb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ bun install bun run dev:up ``` -`dev:up` runs the full stack in Docker behind [OrbStack](https://orbstack.dev) hostnames. Without OrbStack: +`dev:up` runs the full stack in Docker, one stack per worktree, at `http://web..freenary.localhost` and its siblings. A shared [Traefik](https://traefik.io) proxy on port 80 routes every name. Without Docker for the apps: ```bash cp .env.example .env diff --git a/apps/fumadocs/AGENTS.md b/apps/fumadocs/AGENTS.md index 88d27a8..19bcded 100644 --- a/apps/fumadocs/AGENTS.md +++ b/apps/fumadocs/AGENTS.md @@ -7,7 +7,7 @@ The public documentation website. - **Fumadocs** (`fumadocs-core`, `fumadocs-mdx`, `fumadocs-ui` aliased to `@fumadocs/base-ui`) — page tree, MDX pipeline, local search, and the theme's components. - **TanStack Start** on **Vite** — same toolchain as `apps/web`, with SSR plus build-time prerendering. Nitro preset `vercel`; there is no Dockerfile for this app, and `docker-compose.yml` has no `docs` service. - **Tailwind v4** via `@tailwindcss/vite`. `src/app/styles/app.css` imports `tailwindcss`, then Fumadocs' `neutral` and `preset` stylesheets. It does **not** import `@freenary/ui/globals.css`, so the docs palette is Fumadocs' own and does not follow `apps/web`. -- `bun run dev` serves on port **4000**, hardcoded in the `dev` script. `DOCS_PORT` is set by `docker-compose.dev.yml` and read by nothing; `DOCS_HOST` is only an OrbStack domain label. +- `bun run dev` serves on port **4000**, hardcoded in the `dev` script. `DOCS_PORT` is set by `docker-compose.dev.yml` and read by nothing; `DOCS_HOST` is only the Traefik route of the dev stack. ## Layout diff --git a/apps/fumadocs/content/docs/next/contributing/index.mdx b/apps/fumadocs/content/docs/next/contributing/index.mdx index 7391562..3ab9d13 100644 --- a/apps/fumadocs/content/docs/next/contributing/index.mdx +++ b/apps/fumadocs/content/docs/next/contributing/index.mdx @@ -12,7 +12,7 @@ Three moves take you from a clone to a merged change. Set up the repository, run | --- | --- | --- | | Bun | `1.3.14` | The runtime, the package manager and the test runner. `packageManager` in the root `package.json` pins it. | | Docker | Any current release | Both stack paths run PostgreSQL in a container. | -| OrbStack | Any current release | The containerised path only. No development service publishes a host port. | +| Port `80` | Free, or held by a Traefik on the `devproxy` network | The containerised path only. One shared proxy on that port serves every stack. | | PostgreSQL | `18` | The image `bun run db:start` starts for you. You install nothing by hand. | The repository holds a `.nvmrc` file with the content `v25.6.1`. No script and no workflow reads it. Bun runs everything here. @@ -59,11 +59,11 @@ Pick one of the two paths below. Then create your account in the browser. ## The two ways to run the stack -One line separates them. The containerised path runs every part in Docker, behind OrbStack hostnames. The local path runs the three applications on your host. +One line separates them. The containerised path runs every part in Docker, behind one hostname per application under `freenary.localhost`. The local path runs the three applications on your host. | Path | Command | What you get | | --- | --- | --- | -| Containerised | `bun run dev:up` | Five services, one HTTPS hostname per application and per worktree, no host port | +| Containerised | `bun run dev:up` | Five services, one hostname per application and per worktree, and no host port but the shared proxy on `80` | | Local | `bun run db:start`, then `bun run dev` | Three host processes on ports `3000`, `3001` and `4000`, with PostgreSQL on `127.0.0.1:5432` | [Local development stack](/docs/contributing/local-stack) holds the services, the hostnames, the database commands and the first account. @@ -94,10 +94,11 @@ bun run build | 6 | Environment | `bun run env:check` | `scripts/env-sync.ts --check`, which compares `.env.example` and the configuration reference against `packages/env/src/schema.ts` | | 7 | Build | `bun run build` | `turbo run build`, which builds the three applications | -The `compose` job needs no Bun and no install. It checks four things: +The `compose` job needs no Bun and no install. It checks five things: - `docker compose -f docker-compose.dev.yml config --quiet` parses the development stack. - The same command with `-f docker-compose.mail.yml` added parses the Mailpit layer over it. +- The same command against `docker-compose.proxy.yml` parses the development proxy. - The same command against `docker-compose.yml` parses the production stack, with a temporary `.env` that sets `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET`. - The same command with an empty `.env` must fail. A pass means the production file accepted two empty secrets, so the job fails. diff --git a/apps/fumadocs/content/docs/next/contributing/local-stack.mdx b/apps/fumadocs/content/docs/next/contributing/local-stack.mdx index 5476312..47c66ad 100644 --- a/apps/fumadocs/content/docs/next/contributing/local-stack.mdx +++ b/apps/fumadocs/content/docs/next/contributing/local-stack.mdx @@ -8,7 +8,7 @@ Two paths run Freenary on your machine. The containerised path runs every part i | Path | Start it with | It needs | It gives you | | --- | --- | --- | --- | -| Containerised | `bun run dev:up` | Docker and OrbStack | Five services, one HTTPS hostname per application, one isolated stack per worktree | +| Containerised | `bun run dev:up` | Docker and a free port `80` | Five services, one hostname per application, one isolated stack per worktree | | Local | `bun run db:start`, then `bun run dev` | Docker and two variables in two files | Three host processes and PostgreSQL on `127.0.0.1:5432` | ## The containerised path @@ -25,12 +25,12 @@ The stack needs no `.env` file. The `server` service declares `env_file` with `p | --- | --- | --- | --- | --- | | `postgres` | `postgres:18-alpine` | image default | image default | `expose` only, plus the `pgdata` volume and a `pg_isready` health check | | `bootstrap` | `.docker/dockerfile.dev` | `/app/packages/db` | `bun x prisma migrate deploy` | none. It gets `DATABASE_URL` alone | -| `server` | `.docker/dockerfile.dev` | `/app/apps/server` | `bun run --hot src/index.ts` | OrbStack labels, `http-port=3000` | -| `web` | `.docker/dockerfile.dev` | `/app/apps/web` | `bun run dev -- --host 0.0.0.0` | OrbStack labels, `http-port=3001` | -| `docs` | `.docker/dockerfile.dev` | `/app/apps/fumadocs` | `bun run dev -- --host 0.0.0.0` | OrbStack labels, `http-port=4000` | -| `mailpit` | `axllent/mailpit:v1.28` | image default | image default | OrbStack labels, `http-port=8025`. Only with `--mail` | +| `server` | `.docker/dockerfile.dev` | `/app/apps/server` | `bun run --hot src/index.ts` | Traefik labels, port `3000` | +| `web` | `.docker/dockerfile.dev` | `/app/apps/web` | `bun run dev -- --host 0.0.0.0` | Traefik labels, port `3001` | +| `docs` | `.docker/dockerfile.dev` | `/app/apps/fumadocs` | `bun run dev -- --host 0.0.0.0` | Traefik labels, port `4000` | +| `mailpit` | `axllent/mailpit:v1.28` | image default | image default | Traefik labels, port `8025`. Only with `--mail` | -Only `server` loads the root `.env` and the derived values. The `web` service gets `VITE_SERVER_URL` and `SERVER_URL`, which defaults to `http://server:3000`. A server-rendered page therefore reaches the API over the container network. The `docs` service gets `DOCS_PORT`, which no code reads. +Only `server` loads the root `.env` and the derived values. The `web` service gets `VITE_SERVER_URL` and `SERVER_URL`, which defaults to `http://freenary-server:3000`. A server-rendered page therefore reaches the API over the container network. Only the default network of the stack knows that alias. It therefore never reaches the API of another worktree. The `docs` service gets `DOCS_PORT`, which no code reads. The `bootstrap` service is one-shot. It waits for `postgres` to report healthy, then applies the migrations. The `server` service waits for it with the condition `service_completed_successfully`, so the schema lands first. Bootstrap re-runs on every `up`, and it exits `0` when the schema is already current. @@ -68,32 +68,48 @@ The `slugify` helper lowercases the name and collapses each run outside `[a-z0-9 | Field | Template | With `slug=dev` | | --- | --- | --- | | Compose project | `freenary-${slug}` | `freenary-dev` | -| Web host | `web.${slug}.freenary.orb.local` | `web.dev.freenary.orb.local` | -| Server host | `server.${slug}.freenary.orb.local` | `server.dev.freenary.orb.local` | -| Docs host | `docs.${slug}.freenary.orb.local` | `docs.dev.freenary.orb.local` | -| Mail host | `mail.${slug}.freenary.orb.local` | `mail.dev.freenary.orb.local` | -| Cookie domain | `.${slug}.freenary.orb.local` | `.dev.freenary.orb.local` | +| Web host | `web.${slug}.freenary.localhost` | `web.dev.freenary.localhost` | +| Server host | `server.${slug}.freenary.localhost` | `server.dev.freenary.localhost` | +| Docs host | `docs.${slug}.freenary.localhost` | `docs.dev.freenary.localhost` | +| Mail host | `mail.${slug}.freenary.localhost` | `mail.dev.freenary.localhost` | +| Cookie domain | `.${slug}.freenary.localhost` | `.dev.freenary.localhost` | -The wrapper passes the cookie domain as `AUTH_COOKIE_DOMAIN`, so both origins share the session cookie. Every origin uses `https`, because OrbStack fronts each `*.orb.local` name with its own certificate. A plain-HTTP origin breaks the cross-origin calls of the web app on CORS. +The wrapper passes the cookie domain as `AUTH_COOKIE_DOMAIN`, so both origins share the session cookie. Every origin uses plain `http`. Chrome and Firefox treat each `*.localhost` origin as a secure context. The web app therefore works as it does over HTTPS. Safari does not, so use Chrome or Firefox. + +The operating system sends every `*.localhost` name to `127.0.0.1`, so you edit no `/etc/hosts` file. `dev:up` prints the banner before compose runs: ```bash [freenary dev] worktree "" - web https://web..freenary.orb.local - server https://server..freenary.orb.local - docs https://docs..freenary.orb.local - mail https://mail..freenary.orb.local + web http://web..freenary.localhost + server http://server..freenary.localhost + docs http://docs..freenary.localhost + mail http://mail..freenary.localhost project freenary- ``` The `mail` line appears with `--mail` alone. The project name and the hostnames all carry the slug, so two worktrees run side by side. Each one gets its own containers, its own `pgdata` volume and its own URLs. - +### The shared proxy -**No development service publishes a host port.** `postgres` uses `expose`, which opens nothing on the host. `server`, `web` and `docs` declare no `ports` key. Outside OrbStack the two labels do nothing, so the whole stack has no address you can open. Use the local path instead. +**No development service publishes a host port.** `postgres` uses `expose`, which opens nothing on the host. `server`, `web`, `docs` and `mailpit` join a second network, `devproxy`, and carry Traefik labels. One Traefik on port `80` reads those labels and routes each hostname to its container. - +One proxy serves every stack on the machine. That includes each worktree of Freenary, and each other project on the `devproxy` network. The router names carry the slug, so two stacks never replace the routes of each other. + +Before `up`, `start`, `restart`, `run` and `reset`, `scripts/dev.ts` does three checks: + +1. It creates the `devproxy` network when it is absent. It uses the subnet `172.30.0.0/16` when that range is free. +2. It uses a running container that publishes port `80` on `devproxy`, when one exists. The banner then shows `proxy (shared)`. +3. Otherwise it starts `docker-compose.proxy.yml`, a Traefik under the Compose project `devproxy-freenary`. + +A container that holds port `80` off the `devproxy` network stops the start, and the message names it. `docker-compose.yml`, the self-hosting stack, never reads the proxy file. + +`dev:down` leaves the proxy running, because another stack can use it. Stop the Freenary proxy yourself when no stack needs it: + +```bash +docker compose -f docker-compose.proxy.yml down +``` ### File sync @@ -192,7 +208,7 @@ The repository holds no seed script. Open the web app and create the account on `bun run dev:up` configures no email provider. Freenary therefore hides every flow that needs mail. Sign-up returns a session at once, and no screen asks for a code. -Work on those flows through `bun run dev:mail`. It sets `EMAIL_PROVIDER` to `smtp` and points the server at Mailpit. Sign-up then asks for a one-time code, and Mailpit holds the message at `https://mail..freenary.orb.local`. +Work on those flows through `bun run dev:mail`. It sets `EMAIL_PROVIDER` to `smtp` and points the server at Mailpit. Sign-up then asks for a one-time code, and Mailpit holds the message at `http://mail..freenary.localhost`. Read the code in that inbox, then type it in the form. The local path sets no email provider either, so a sign-up there returns a session at once. @@ -207,7 +223,9 @@ Read the code in that inbox, then type it in the form. The local path sets no em | A permanent 500 that names `@/paraglide/server.js` | Vite cached a failed resolution after the paraglide directory vanished | `bun scripts/dev.ts restart web` | | Prisma methods or types that do not match the schema | A stale generated client | `bun run db:generate` | | `bind: address already in use` on `3000` | Another `bun run dev`, or a bare `vite dev` inside `apps/fumadocs` | Stop that process, or start the site through its `dev` script | -| `bind: address already in use` on `3001` or `4000` | Another host process holds the port | Stop it, or use the containerised path, which publishes no host port | +| `bind: address already in use` on `3001` or `4000` | Another host process holds the port | Stop it, or use the containerised path, which publishes no application port | +| `bind: address already in use` on `80` when the proxy starts | A process outside Docker holds port `80` | Stop that process, then run `bun run dev:up` again | +| Every hostname answers `404 page not found` | The proxy has no route for that name, because the stack of that slug is not running | `bun scripts/dev.ts ps`, then `bun run dev:up` | | The production compose file refuses to parse | An unset or empty `POSTGRES_PASSWORD` or `BETTER_AUTH_SECRET` | Set both in the root `.env` | | A route the router does not know, or type errors in `routeTree.gen.ts` | A stale generated route tree | Restart the web development server | | Every transaction stays uncategorised | The merchant dictionary artifact is absent | `bun run build:data` on the host | diff --git a/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx b/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx index 769753e..2f68dda 100644 --- a/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx +++ b/apps/fumadocs/content/docs/next/contributing/writing-docs.mdx @@ -26,7 +26,7 @@ cd apps/fumadocs bun run dev ``` -The `dev` script is `vite dev --port=4000`, so the site answers on `http://localhost:4000`. The flag overrides `server.port: 3000` in `apps/fumadocs/vite.config.ts`. The development stack serves the same app at `https://docs..freenary.orb.local`. See [Local development stack](/docs/contributing/local-stack). +The `dev` script is `vite dev --port=4000`, so the site answers on `http://localhost:4000`. The flag overrides `server.port: 3000` in `apps/fumadocs/vite.config.ts`. The development stack serves the same app at `http://docs..freenary.localhost`. See [Local development stack](/docs/contributing/local-stack). ## Versions diff --git a/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx b/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx index ea13c09..c275885 100644 --- a/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx +++ b/apps/fumadocs/content/docs/next/self-hosting/configuration.mdx @@ -308,10 +308,10 @@ More Compose-only variables belong to the development stack alone. The productio | Variable | Default | What it does | | --- | --- | --- | | `FREENARY_SLUG` | the git branch, else the directory name, else `dev` | Names the Compose project and every hostname of one worktree stack. It reaches Compose through the `-p` flag. | -| `SERVER_HOST` | `server..freenary.orb.local` | The API server hostname. It fills `BETTER_AUTH_URL`. | -| `WEB_HOST` | `web..freenary.orb.local` | The web app hostname. It fills `CORS_ORIGIN`. | -| `DOCS_HOST` | `docs..freenary.orb.local` | The documentation hostname, in the OrbStack label alone. | -| `MAIL_HOST` | `mail..freenary.orb.local` | The Mailpit inbox hostname, in the OrbStack label alone. It applies to `bun run dev:mail`. | +| `SERVER_HOST` | `server..freenary.localhost` | The API server hostname. It fills `BETTER_AUTH_URL`. | +| `WEB_HOST` | `web..freenary.localhost` | The web app hostname. It fills `CORS_ORIGIN`. | +| `DOCS_HOST` | `docs..freenary.localhost` | The documentation hostname, in the Traefik route alone. | +| `MAIL_HOST` | `mail..freenary.localhost` | The Mailpit inbox hostname, in the Traefik route alone. It applies to `bun run dev:mail`. | | `DOCS_PORT` | `4000` | The documentation port inside its own container. | {/* env-sync:end */} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bfb1d89..2f3d735 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,13 +32,21 @@ x-app-env-file: &app-env-file x-app-env: &app-env DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-password}@postgres:5432/${POSTGRES_DB:-freenary} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-dev_secret_change_me_at_least_32chars} - # OrbStack fronts *.orb.local with auto-HTTPS (trusted local CA); browsers land on - # https, so these origins must be https or the SPA's cross-origin calls fail CORS. - BETTER_AUTH_URL: https://${SERVER_HOST:-server.freenary.orb.local} - CORS_ORIGIN: https://${WEB_HOST:-web.freenary.orb.local} + # The shared devproxy Traefik serves every *.localhost name over plain HTTP. + # Browsers treat *.localhost as a secure context, and web and server share the + # parent domain in AUTH_COOKIE_DOMAIN, so a Lax cookie reaches both. + BETTER_AUTH_URL: http://${SERVER_HOST:-server.freenary.localhost} + CORS_ORIGIN: http://${WEB_HOST:-web.freenary.localhost} AUTH_COOKIE_DOMAIN: ${AUTH_COOKIE_DOMAIN:-} NODE_ENV: development +# Joined by every service the browser reaches. `devproxy` is the network of the +# shared Traefik that `scripts/dev.ts` starts or reuses; the stack's own +# default network keeps postgres off it. +x-proxied: &proxied + - default + - devproxy + services: postgres: image: postgres:18-alpine @@ -80,11 +88,18 @@ services: env_file: *app-env-file environment: <<: *app-env + # Compose also registers `server` on devproxy, where every worktree's API + # answers to it. In-stack callers use this alias, which only the stack's + # own default network knows. + networks: + default: + aliases: [freenary-server] + devproxy: {} labels: - # OrbStack's built-in proxy serves this container at server.freenary.orb.local; - # ignored outside OrbStack. - - dev.orbstack.domains=${SERVER_HOST:-server.freenary.orb.local} - - dev.orbstack.http-port=3000 + - traefik.enable=true + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-server.rule=Host(`${SERVER_HOST:-server.freenary.localhost}`) + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-server.entrypoints=web + - traefik.http.services.freenary-${FREENARY_SLUG:-dev}-server.loadbalancer.server.port=3000 develop: watch: *app-watch depends_on: @@ -99,13 +114,16 @@ services: working_dir: /app/apps/web command: bun run dev -- --host 0.0.0.0 environment: - VITE_SERVER_URL: https://${SERVER_HOST:-server.freenary.orb.local} + VITE_SERVER_URL: http://${SERVER_HOST:-server.freenary.localhost} # What the web app's own server calls while rendering a page: the - # container network, since OrbStack's local CA is not trusted inside it. - SERVER_URL: ${SERVER_URL:-http://server:3000} + # container network, since `*.localhost` resolves to the container itself. + SERVER_URL: ${SERVER_URL:-http://freenary-server:3000} + networks: *proxied labels: - - dev.orbstack.domains=${WEB_HOST:-web.freenary.orb.local} - - dev.orbstack.http-port=3001 + - traefik.enable=true + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-web.rule=Host(`${WEB_HOST:-web.freenary.localhost}`) + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-web.entrypoints=web + - traefik.http.services.freenary-${FREENARY_SLUG:-dev}-web.loadbalancer.server.port=3001 develop: watch: *app-watch depends_on: @@ -118,12 +136,20 @@ services: command: bun run dev -- --host 0.0.0.0 environment: DOCS_PORT: ${DOCS_PORT:-4000} + networks: *proxied labels: - - dev.orbstack.domains=${DOCS_HOST:-docs.freenary.orb.local} - - dev.orbstack.http-port=4000 + - traefik.enable=true + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-docs.rule=Host(`${DOCS_HOST:-docs.freenary.localhost}`) + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-docs.entrypoints=web + - traefik.http.services.freenary-${FREENARY_SLUG:-dev}-docs.loadbalancer.server.port=4000 develop: watch: *app-watch restart: unless-stopped volumes: pgdata: + +networks: + devproxy: + name: devproxy + external: true diff --git a/docker-compose.mail.yml b/docker-compose.mail.yml index 2fea159..099b5cc 100644 --- a/docker-compose.mail.yml +++ b/docker-compose.mail.yml @@ -14,16 +14,24 @@ services: MP_SMTP_AUTH_ALLOW_INSECURE: 1 expose: - "1025" + # `mailpit` also resolves on devproxy, to every worktree's inbox; the + # server reaches this one through a default-network-only alias. + networks: + default: + aliases: [freenary-mailpit] + devproxy: {} labels: - - dev.orbstack.domains=${MAIL_HOST:-mail.freenary.orb.local} - - dev.orbstack.http-port=8025 + - traefik.enable=true + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-mail.rule=Host(`${MAIL_HOST:-mail.freenary.localhost}`) + - traefik.http.routers.freenary-${FREENARY_SLUG:-dev}-mail.entrypoints=web + - traefik.http.services.freenary-${FREENARY_SLUG:-dev}-mail.loadbalancer.server.port=8025 restart: unless-stopped server: environment: EMAIL_PROVIDER: smtp EMAIL_FROM: Freenary - SMTP_HOST: mailpit + SMTP_HOST: freenary-mailpit SMTP_PORT: 1025 SMTP_SECURE: false depends_on: diff --git a/docker-compose.proxy.yml b/docker-compose.proxy.yml new file mode 100644 index 0000000..35347cd --- /dev/null +++ b/docker-compose.proxy.yml @@ -0,0 +1,41 @@ +# The development reverse proxy. Development only: docker-compose.yml, the +# self-hosting stack, never reads this file. +# +# ONE Traefik on host port 80 serves every development stack on the machine, +# each worktree of Freenary and any other project that joins the `devproxy` +# network. `scripts/dev.ts` starts this file only when no container already +# publishes port 80 on that network; when one does, it reuses it, so a second +# proxy never fights the first for the port. +# +# Routes are not configured here. Each stack declares its own with Traefik +# labels, and the docker provider picks them up when a container starts. +# +# Stop it by hand when no stack needs it: +# docker compose -f docker-compose.proxy.yml down +name: devproxy-freenary + +services: + traefik: + # v3.7 or newer: older Traefik negotiates Docker API 1.24, which Docker + # Engine 29 refuses, and every route then answers 404. + image: traefik:v3.7 + restart: unless-stopped + ports: + - "80:80" + command: + - --providers.docker=true + - --providers.docker.exposedByDefault=false + - --providers.docker.network=devproxy + - --entrypoints.web.address=:80 + # The assistant streams its answer; no idle cut-off mid-stream. + - --entrypoints.web.transport.respondingTimeouts.idleTimeout=0 + - --log.level=INFO + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - devproxy + +networks: + devproxy: + name: devproxy + external: true diff --git a/docs/engineering/platform.md b/docs/engineering/platform.md index f50e5e4..1714b25 100644 --- a/docs/engineering/platform.md +++ b/docs/engineering/platform.md @@ -46,10 +46,13 @@ Durable facts that the code cannot carry in a name. Each bullet names the module ## Dev stack -- `scripts/dev-identity.ts › MAX_SLUG_LENGTH` — 40 characters keeps every derived label of `..freenary.orb.local` inside the 63-character DNS label limit, with headroom for the prefix and the suffix. +- `scripts/dev-identity.ts › MAX_SLUG_LENGTH` — 40 characters keeps every derived label of `..freenary.localhost` inside the 63-character DNS label limit, with headroom for the prefix and the suffix. - `scripts/dev-identity.ts › deriveDevIdentity` — git refuses to check out the same branch in two worktrees, which is what makes a branch-derived slug collision-free across concurrent dev stacks. -- `scripts/dev-identity.ts › DevIdentity.cookieDomain` — `.{slug}.freenary.orb.local` is the parent both origins sit under, and `resolveCookiePolicy` throws on a domain that is not a parent of both hosts. Changing the host shape here breaks sign-in in the dev stack; `scripts/dev-identity.test.ts` asserts the parenthood. -- `scripts/dev.ts › main` — `docker compose -p ` is the highest-precedence project-name lever and holds regardless of the `name: freenary` key inside `docker-compose.dev.yml`. It namespaces containers, the network and the volumes, so several worktrees run the stack at once without container-name, OrbStack-hostname or CORS collisions, and it is what scopes `reset`'s `down -v` to this worktree's volumes. +- `scripts/dev-identity.ts › DevIdentity.cookieDomain` — `.{slug}.freenary.localhost` is the parent both origins sit under, and `resolveCookiePolicy` throws on a domain that is not a parent of both hosts or that is a single label. A bare `.localhost` is therefore refused, which is why the hosts carry the `freenary` label and the slug as dotted labels rather than one `web-.localhost` label. Changing the host shape here breaks sign-in in the dev stack; `scripts/dev-identity.test.ts` asserts the parenthood. +- `scripts/dev-identity.ts › SCHEME` — plain `http`: Chrome and Firefox treat every `*.localhost` origin as a secure context, and web and server are same-site under the declared cookie parent, so the session cookie is `SameSite=Lax` without `Secure`. HTTPS would need a local CA that no container trusts. +- `scripts/dev.ts › main` — `docker compose -p ` is the highest-precedence project-name lever and holds regardless of the `name: freenary` key inside `docker-compose.dev.yml`. It namespaces containers, the network and the volumes, so several worktrees run the stack at once without container-name, hostname or CORS collisions, and it is what scopes `reset`'s `down -v` to this worktree's volumes. +- `scripts/dev.ts › ensureProxy` — Traefik's router namespace is global across every stack on the `devproxy` network, which is why each label names its router `freenary--`. Only one process can bind host port 80, so the script reuses any running container that publishes 80 on `devproxy` (another project's shared proxy included) and starts `docker-compose.proxy.yml` only when none does. That file runs under its own project name, `devproxy-freenary`, never `devproxy`: a second `up` under another project's name would recreate that project's proxy with this file's configuration. +- `scripts/dev.ts › PROXY_SUBNET` — `172.30.0.0/16` is the subnet other projects that share `devproxy` create it with, and they pin their proxy at `172.30.0.2` for container-to-proxy callbacks; a network created on any other range would stay (they keep an existing `devproxy`) and break that pin. When the range is already taken on the machine, the network is created on a Docker-chosen subnet instead. The same sharing is why `server` and `mailpit` carry the aliases `freenary-server` and `freenary-mailpit` on the default network only: Compose registers a service's own name on every network it joins, so a bare `server` resolves on `devproxy` to the API of every running worktree. - `scripts/dev.ts › readBranch` — `git rev-parse --abbrev-ref HEAD` prints the literal `HEAD` on a detached HEAD, which is why that ref is treated as "no branch" and the worktree directory name is used instead. - `scripts/dev.ts › readEnvOverride` — a value already exported in the shell outranks the worktree-local `.env`, which is read with one regexp so the script needs no dotenv dependency. `FREENARY_SLUG=… bun run dev:up` therefore renames a stack without editing a file. - `scripts/env-sync.ts › main` — a variable used to be declared in four places: the schema, `.env.example`, the tables of `self-hosting/configuration.mdx` and the `x-app-env` pass-through list. Only the schema had a gate, so the other three drifted silently and a reader met a variable the code had renamed or dropped. The schema is now the one authored source and both artifacts are generated from it, which turns a missing description, example or `onError` into a failed build instead of a reader's surprise. @@ -66,7 +69,7 @@ Durable facts that the code cannot carry in a name. Each bullet names the module - `apps/fumadocs/src/app/routes/api.search.ts › buildIndex` — each index entry is tagged with its version and `src/routes/__root.tsx` passes that version as the search `defaultTag`; off the docs tree the landing page answers for the newest release instead. A reader on 1.2 must never get a 1.3 hit, and `src/routes/api.chat.ts › indexByVersion` keeps one flexsearch index per version for the same reason. - `apps/fumadocs/src/shared/ui/mdx.tsx › extraMdxComponents` — an MDX component absent from this record renders nothing and raises no build error, so `apps/fumadocs/scripts/check-docs.ts` reads `getMDXComponents()` and lucide's own `icons` record from the app rather than from a copied list, and cannot drift from what the site renders. - `apps/fumadocs/src/shared/ui/mdx.tsx › VersionedCard` — `Card` renders its own anchor, so the `a` override never reaches its `href`; version resolution needs this wrapper. -- `apps/fumadocs/vite.config.ts › server.allowedHosts` — the dev stack serves this app at a per-worktree OrbStack hostname, which Vite refuses by default, and that is the page a docs change has to be read on. +- `apps/fumadocs/vite.config.ts › server.allowedHosts` — the dev stack serves this app at a per-worktree `*.localhost` hostname behind Traefik, which Vite refuses by default, and that is the page a docs change has to be read on. - `.github/workflows/release.yml › tag` — the workflow refuses a missing documentation snapshot instead of writing one. The `main and dev` ruleset carries no bypass actor and requires a pull request plus the `ci` and `compose` checks, so a `GITHUB_TOKEN` push to `main` is rejected with GH013; a snapshot that lands through a pull request passes `docs:check` and the build like every other commit. The `Tag` step sets `user.name` and `user.email` itself: an annotated tag records a tagger, a runner has no identity, and `git tag -a` fails with `empty ident name`. - `.github/workflows/release.yml › publish` — with no earlier `v*` tag, GitHub's generated notes start at the previous _release_, which is a `data-` merchant-data release, so the first version lists its merged pull requests itself. `pull-requests: read` in the top-level `permissions` exists for that one `gh pr list` call, because naming any scope sets the rest to `none`. - `apps/fumadocs/scripts/snapshot-version.ts › STAGING_DIR_OUTSIDE_DOCS` — the copy is staged outside `content/docs` and moved in with one `rename`, so an interrupted run leaves no half-written version for the site to serve, and no folder a retry would mistake for a finished snapshot. diff --git a/packages/email/AGENTS.md b/packages/email/AGENTS.md index 293a78a..c240963 100644 --- a/packages/email/AGENTS.md +++ b/packages/email/AGENTS.md @@ -26,7 +26,7 @@ src/ - **The registry resolves one adapter from `EMAIL_PROVIDER`**, and `emailProvider` is built once at module import. `createEmailProvider` takes an explicit `EmailSettings` object rather than reading `env` itself, so the resolution rules are testable without mutating the environment. - **An adapter is built once and keeps its connections.** `smtp` passes `pool: true`, because nodemailer pools nothing by default and would otherwise open a fresh TCP and TLS handshake per message; `resend` needs nothing, since `fetch` reuses the agent's connections. A new adapter that holds a connection reuses one client for the process rather than one per `send`. - **It fails fast, not at delivery.** A provider named but half-configured (`resend` without `RESEND_API_KEY`, `smtp` without `SMTP_HOST`, either without `EMAIL_FROM`) throws at import with the missing variable in the message. Never soften that into a warning or a no-op fallback: a swallowed one-time code is discovered by the user, not the operator. -- **Development gets a real transport, never a printing one.** `bun run dev:mail` layers `docker-compose.mail.yml` over the dev stack: it starts Mailpit, points `smtp` at it, and holds every message in a web inbox at `https://mail..freenary.orb.local`. Never add an adapter that writes a one-time code to a log or to standard output — it is indistinguishable from a working transport until somebody reads the log, and it is one environment variable away from doing that in production. +- **Development gets a real transport, never a printing one.** `bun run dev:mail` layers `docker-compose.mail.yml` over the dev stack: it starts Mailpit, points `smtp` at it, and holds every message in a web inbox at `http://mail..freenary.localhost`. Never add an adapter that writes a one-time code to a log or to standard output — it is indistinguishable from a working transport until somebody reads the log, and it is one environment variable away from doing that in production. - **No provider configured is a supported state**: `emailProvider` is `null` and `isEmailEnabled` is `false`. Callers hide what needs mail rather than offering and failing it — `packages/auth` skips the `emailOTP` plugin and drops `requireEmailVerification`, so an instance with no transport has no address confirmation and no password reset. `sendEmail` still throws if something calls it anyway. - Adding a transport is one file in `providers/`, one `EMAIL_PROVIDER` enum member in `packages/env/src/schema.ts`, the matching member of `EmailProviderName` here, and one `Match.when` arm in `createEmailProvider`. The resolution ends in `Match.exhaustive`, so the arm is a compile error until it exists. Adapters take their credentials as constructor arguments and never read `env`. - **Message copy is not here.** Subjects and bodies live with the feature that sends them — `packages/auth/src/emails.ts` for authentication codes. That copy is English only: Paraglide's catalogs live in `apps/web`, and the locale a user picked in the browser is not carried on an auth request. diff --git a/scripts/dev-identity.test.ts b/scripts/dev-identity.test.ts index 6c0b824..216c88e 100644 --- a/scripts/dev-identity.test.ts +++ b/scripts/dev-identity.test.ts @@ -90,26 +90,26 @@ describe("deriveDevIdentity URL / host consistency", () => { }); describe("deriveDevIdentity host shapes", () => { - test("produces https + orb.local hosts", () => { + test("produces http + freenary.localhost hosts", () => { const id = deriveDevIdentity({ branch: "feat/projects" }); - expect(new URL(id.corsOrigin).protocol).toBe("https:"); - expect(id.webHost).toBe("web.feat-projects.freenary.orb.local"); - expect(id.serverHost).toBe("server.feat-projects.freenary.orb.local"); - expect(id.corsOrigin).toBe("https://web.feat-projects.freenary.orb.local"); + expect(new URL(id.corsOrigin).protocol).toBe("http:"); + expect(id.webHost).toBe("web.feat-projects.freenary.localhost"); + expect(id.serverHost).toBe("server.feat-projects.freenary.localhost"); + expect(id.corsOrigin).toBe("http://web.feat-projects.freenary.localhost"); expect(id.betterAuthUrl).toBe( - "https://server.feat-projects.freenary.orb.local" + "http://server.feat-projects.freenary.localhost" ); expect(id.viteServerUrl).toBe( - "https://server.feat-projects.freenary.orb.local" + "http://server.feat-projects.freenary.localhost" ); - expect(id.docsHost).toBe("docs.feat-projects.freenary.orb.local"); - expect(id.docsUrl).toBe("https://docs.feat-projects.freenary.orb.local"); - expect(id.mailHost).toBe("mail.feat-projects.freenary.orb.local"); - expect(id.mailUrl).toBe("https://mail.feat-projects.freenary.orb.local"); - expect(id.cookieDomain).toBe(".feat-projects.freenary.orb.local"); + expect(id.docsHost).toBe("docs.feat-projects.freenary.localhost"); + expect(id.docsUrl).toBe("http://docs.feat-projects.freenary.localhost"); + expect(id.mailHost).toBe("mail.feat-projects.freenary.localhost"); + expect(id.mailUrl).toBe("http://mail.feat-projects.freenary.localhost"); + expect(id.cookieDomain).toBe(".feat-projects.freenary.localhost"); }); }); diff --git a/scripts/dev-identity.ts b/scripts/dev-identity.ts index 726540b..c5950fe 100644 --- a/scripts/dev-identity.ts +++ b/scripts/dev-identity.ts @@ -22,7 +22,8 @@ export interface DevIdentity { const DEFAULT_SLUG = "dev"; const MAX_SLUG_LENGTH = 40; const PROJECT_PREFIX = "freenary"; -const ORBSTACK_SUFFIX = "freenary.orb.local"; +const HOST_SUFFIX = "freenary.localhost"; +const SCHEME = "http"; const NON_LABEL_RUN = /[^a-z0-9]+/gu; const EDGE_DASHES = /^-+|-+$/gu; @@ -48,24 +49,24 @@ export const deriveDevIdentity = (input: DevIdentityInput): DevIdentity => { toDnsLabel(input.dir) || DEFAULT_SLUG; - const webHost = `web.${slug}.${ORBSTACK_SUFFIX}`; - const serverHost = `server.${slug}.${ORBSTACK_SUFFIX}`; - const docsHost = `docs.${slug}.${ORBSTACK_SUFFIX}`; - const mailHost = `mail.${slug}.${ORBSTACK_SUFFIX}`; - const parentDomainOfWebAndServerHosts = `.${slug}.${ORBSTACK_SUFFIX}`; + const webHost = `web.${slug}.${HOST_SUFFIX}`; + const serverHost = `server.${slug}.${HOST_SUFFIX}`; + const docsHost = `docs.${slug}.${HOST_SUFFIX}`; + const mailHost = `mail.${slug}.${HOST_SUFFIX}`; + const parentDomainOfWebAndServerHosts = `.${slug}.${HOST_SUFFIX}`; return { - betterAuthUrl: `https://${serverHost}`, + betterAuthUrl: `${SCHEME}://${serverHost}`, composeProjectName: `${PROJECT_PREFIX}-${slug}`, cookieDomain: parentDomainOfWebAndServerHosts, - corsOrigin: `https://${webHost}`, + corsOrigin: `${SCHEME}://${webHost}`, docsHost, - docsUrl: `https://${docsHost}`, + docsUrl: `${SCHEME}://${docsHost}`, mailHost, - mailUrl: `https://${mailHost}`, + mailUrl: `${SCHEME}://${mailHost}`, serverHost, slug, - viteServerUrl: `https://${serverHost}`, + viteServerUrl: `${SCHEME}://${serverHost}`, webHost, }; }; diff --git a/scripts/dev.ts b/scripts/dev.ts index 466ab38..24c773d 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -8,11 +8,24 @@ import { deriveDevIdentity } from "./dev-identity"; const DEV_COMPOSE_FILE = "docker-compose.dev.yml"; const MAIL_COMPOSE_FILE = "docker-compose.mail.yml"; +const PROXY_COMPOSE_FILE = "docker-compose.proxy.yml"; +const PROXY_NETWORK = "devproxy"; +const PROXY_SUBNET = "172.30.0.0/16"; +const PROXY_HOST_PORT = "80"; const MAIL_FLAG = "--mail"; const WORKTREE_ENV_FILE = ".env"; const DETACHED_HEAD_REF = "HEAD"; const RESET_VERB = "reset"; const SPAWN_FAILURE_EXIT_CODE = 1; + +const STARTING_VERBS: Partial> = { + [RESET_VERB]: true, + restart: true, + run: true, + start: true, + up: true, +}; + const QUOTE_EDGES = /^["']|["']$/gu; const readWorktreeEnvFile = Option.liftThrowable((file: string): string => @@ -63,6 +76,84 @@ const compose = (args: string[], env: typeof process.env): number => { return composed.status ?? SPAWN_FAILURE_EXIT_CODE; }; +const dockerLines = (args: string[]): string[] | null => { + const listed = spawnSync("docker", args, { encoding: "utf-8" }); + + return listed.status === 0 + ? listed.stdout.split("\n").filter((line) => line.trim().length > 0) + : null; +}; + +const runningOnPort = (extraFilters: string[]): string[] => + dockerLines([ + "ps", + "--filter", + `publish=${PROXY_HOST_PORT}`, + "--filter", + "status=running", + ...extraFilters, + "--format", + "{{.Names}}", + ]) ?? []; + +const ensureProxyNetwork = (): boolean => { + const exists = dockerLines(["network", "inspect", PROXY_NETWORK]) !== null; + + if (exists) { + return true; + } + + process.stdout.write( + `[freenary dev] creating network ${PROXY_NETWORK} (${PROXY_SUBNET})\n` + ); + + const createdOnSharedSubnet = + dockerLines([ + "network", + "create", + "--subnet", + PROXY_SUBNET, + PROXY_NETWORK, + ]) !== null; + + return ( + createdOnSharedSubnet || + dockerLines(["network", "create", PROXY_NETWORK]) !== null + ); +}; + +const ensureProxy = (env: typeof process.env): number => { + if (!ensureProxyNetwork()) { + process.stderr.write( + `[freenary dev] could not create the ${PROXY_NETWORK} network. Is Docker running?\n` + ); + + return SPAWN_FAILURE_EXIT_CODE; + } + + const [sharedProxy] = runningOnPort(["--filter", `network=${PROXY_NETWORK}`]); + + if (sharedProxy) { + process.stdout.write(` proxy ${sharedProxy} (shared)\n`); + + return 0; + } + + const [portHolder] = runningOnPort([]); + + if (portHolder) { + process.stderr.write( + `[freenary dev] container ${portHolder} holds port ${PROXY_HOST_PORT} and is not on the ${PROXY_NETWORK} network, so it cannot route this stack. Stop it, or attach it with: docker network connect ${PROXY_NETWORK} ${portHolder}\n` + ); + + return SPAWN_FAILURE_EXIT_CODE; + } + + process.stdout.write(` proxy starting ${PROXY_COMPOSE_FILE}\n`); + + return compose(["-f", PROXY_COMPOSE_FILE, "up", "-d"], env); +}; + const main = (): number => { const argv = process.argv.slice(2); const withMail = argv.includes(MAIL_FLAG); @@ -96,6 +187,13 @@ const main = (): number => { `[freenary dev] worktree "${identity.slug}"\n web ${identity.corsOrigin}\n server ${identity.betterAuthUrl}\n docs ${identity.docsUrl}\n${withMail ? ` mail ${identity.mailUrl}\n` : ""} project ${identity.composeProjectName}\n` ); + const startsTheStack = STARTING_VERBS[rest[0] ?? ""] === true; + const proxyStatus = startsTheStack ? ensureProxy(env) : 0; + + if (proxyStatus !== 0) { + return proxyStatus; + } + if (rest[0] === RESET_VERB) { const downWithVolumes = compose([...base, "down", "-v"], env); diff --git a/scripts/env-sync.ts b/scripts/env-sync.ts index 85d4c16..2b15557 100755 --- a/scripts/env-sync.ts +++ b/scripts/env-sync.ts @@ -40,6 +40,7 @@ const COMPOSE_FILES = [ "docker-compose.yml", "docker-compose.dev.yml", "docker-compose.mail.yml", + "docker-compose.proxy.yml", ]; const SOURCE_GLOBS = [ @@ -211,39 +212,39 @@ const EXTERNAL: ExternalVariable[] = [ name: "FREENARY_SLUG", }, { - default: "`server..freenary.orb.local`", + default: "`server..freenary.localhost`", description: "The API server hostname. It fills `BETTER_AUTH_URL`.", envFile: "commented", envFileSection: "development", - example: "server.freenary.orb.local", + example: "server.freenary.localhost", kind: "compose-dev", name: "SERVER_HOST", }, { - default: "`web..freenary.orb.local`", + default: "`web..freenary.localhost`", description: "The web app hostname. It fills `CORS_ORIGIN`.", envFile: "commented", envFileSection: "development", - example: "web.freenary.orb.local", + example: "web.freenary.localhost", kind: "compose-dev", name: "WEB_HOST", }, { - default: "`docs..freenary.orb.local`", - description: "The documentation hostname, in the OrbStack label alone.", + default: "`docs..freenary.localhost`", + description: "The documentation hostname, in the Traefik route alone.", envFile: "commented", envFileSection: "development", - example: "docs.freenary.orb.local", + example: "docs.freenary.localhost", kind: "compose-dev", name: "DOCS_HOST", }, { - default: "`mail..freenary.orb.local`", + default: "`mail..freenary.localhost`", description: - "The Mailpit inbox hostname, in the OrbStack label alone. It applies to `bun run dev:mail`.", + "The Mailpit inbox hostname, in the Traefik route alone. It applies to `bun run dev:mail`.", envFile: "commented", envFileSection: "development", - example: "mail.freenary.orb.local", + example: "mail.freenary.localhost", kind: "compose-dev", name: "MAIL_HOST", },