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", },