From ed0212108ee282412630fd4c86b39f1741e51e2d Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:13:14 -0600 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=93=9D=20docs(portless):=20brainstorm?= =?UTF-8?q?=20+=20implementation=20plan=20for=20Turborepo=20dev=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ess-turborepo-dev-workflow-requirements.md | 144 +++++++++ ...at-portless-turborepo-dev-workflow-plan.md | 295 ++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md create mode 100644 docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md diff --git a/docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md b/docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md new file mode 100644 index 0000000000..a1eb3d19d4 --- /dev/null +++ b/docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md @@ -0,0 +1,144 @@ +--- +date: 2026-05-22 +topic: portless-turborepo-dev-workflow +--- + +# Portless Turborepo Dev Workflow + +## Summary + +PackRat's Turborepo development workflow should use Portless so local services get stable named URLs instead of fixed, colliding ports. The ready state includes web apps, Cloudflare Worker services, MCP, and Expo development, with Expo simulator support first and physical-device LAN support before the workflow is considered complete. + +--- + +## Problem Frame + +PackRat is moving toward a Turborepo-based monorepo workflow, and the current local development shape still relies on package-level commands and fixed or implicit ports. That is workable for one human running one service, but it becomes fragile when multiple agents or worktrees start services independently. + +Subagents need predictable service addresses that do not conflict with each other. Without that, agents either reuse occupied ports, silently point at the wrong local process, or require manual port cleanup and environment edits before they can test changes. + +Expo raises the bar beyond browser-only development. The mobile app consumes a configured API URL, and local readiness is incomplete if web/API services have named URLs but the Expo app still depends on manual API URL juggling. + +--- + +## Actors + +- A1. Human developer: Starts local PackRat workflows, reviews agent output, and may run Expo on simulator or physical devices. +- A2. Coding agent or subagent: Starts and tests local services in a worktree without coordinating ports manually. +- A3. PackRat local services: Web apps, Cloudflare Worker services, MCP, and Expo Metro processes that need stable local addresses. +- A4. Mobile test device or simulator: Runs the Expo app and connects to the local API during development. + +--- + +## Key Flows + +- F1. Multi-agent web/API development + - **Trigger:** A human or agent starts the PackRat dev workflow in a worktree. + - **Actors:** A1, A2, A3 + - **Steps:** Services start through the Turborepo dev workflow, each service receives a stable named local URL, and agents use those URLs instead of guessing ports. + - **Outcome:** Multiple worktrees or agents can run dev services at the same time without local port collisions. + - **Covered by:** R1, R2, R3, R4, R9 + +- F2. Expo simulator local API development + - **Trigger:** A developer or agent runs the Expo app locally against the development API. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The local API service starts with a named Portless URL, Expo receives a matching local API URL, and the simulator or emulator can make authenticated API calls to that local service. + - **Outcome:** Expo local development works without manually editing API ports. + - **Covered by:** R5, R6, R8, R9 + +- F3. Expo physical-device local API development + - **Trigger:** A developer tests the Expo app on a phone or tablet on the same network as the development machine. + - **Actors:** A1, A3, A4 + - **Steps:** Portless LAN mode exposes the local API through a device-reachable local name, the Expo app uses that URL, and platform networking requirements are satisfied. + - **Outcome:** Physical-device testing can use the same local development stack without falling back to production or manual IP-address wiring. + - **Covered by:** R6, R7, R8, R10 + +--- + +## Requirements + +**Named local services** + +- R1. The PackRat dev workflow must expose runnable local services through stable names rather than requiring humans or agents to know fixed localhost ports. +- R2. The workflow must support running multiple services from the monorepo through the Turborepo development task. +- R3. The workflow must preserve direct service-level development for contributors who only want to start one app or service. +- R4. Service names must be predictable enough for agents to infer the intended target, such as web, admin, guides, API, and MCP roles. + +**Agent and worktree behavior** + +- R5. Worktree-local development must avoid collisions with the main checkout and with other active worktrees. +- R6. Agents must be able to discover or be given the active local service URL without reading terminal output from another process. +- R7. The workflow must include cleanup or stale-process handling expectations so abandoned agent sessions do not keep breaking later runs. + +**Expo readiness** + +- R8. Expo local development must be able to target the local PackRat API through the same named-service strategy used by the rest of the dev workflow. +- R9. Simulator and emulator support must be part of the first usable Portless workflow, not an optional later integration. +- R10. Physical-device support must be included before the workflow is considered complete, using LAN-reachable local service names where appropriate. +- R11. Expo readiness must not require broad API client redesign; the goal is reliable local URL selection for development. + +**Compatibility and validation** + +- R12. Next.js-based apps must work through the named URL workflow without losing normal development server behavior. +- R13. Cloudflare Worker services must be explicitly validated because their local server behavior may differ from standard Node or Next.js apps. +- R14. The workflow must retain a bypass path for contributors who need to run the underlying dev command directly. +- R15. Documentation must explain the normal local workflow, the Expo simulator workflow, the physical-device LAN workflow, and recovery steps for stale local services. + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R2, R4, R5.** Given one agent is running PackRat services in one worktree, when another agent starts the dev workflow in a second worktree, both get distinct reachable service URLs and neither fails because a port is already occupied. +- AE2. **Covers R3, R14.** Given a contributor only wants to work on one web app, when they start that service directly, they can still use the underlying dev command without being forced through the full monorepo workflow. +- AE3. **Covers R8, R9.** Given the local API is running through the named-service workflow, when the Expo app runs in a simulator or emulator, API calls target the local API without manually editing a hardcoded port. +- AE4. **Covers R10.** Given Portless LAN mode is enabled and a phone is on the same network, when the Expo app runs on the phone, it can reach the local API through a LAN-reachable local service URL. +- AE5. **Covers R13.** Given a Cloudflare Worker service is started through the workflow, when an agent calls its named local URL, the request reaches the intended worker service and not a stale or unrelated process. + +--- + +## Success Criteria + +- Developers and agents can run PackRat local services from multiple worktrees without manual port assignment or cleanup as the normal path. +- Expo development can target the local API in simulator/emulator mode and has a documented path for physical-device testing. +- A downstream planner can identify the required services, validation scenarios, and non-goals without inventing product behavior. +- The workflow remains understandable for humans who do not use subagents heavily. + +--- + +## Scope Boundaries + +- Local development only; production, staging, and deployed preview URL strategy are out of scope. +- No broad rewrite of the Expo API client or environment system unless required to select the local API URL reliably. +- No requirement to Portless-manage packages that do not run a persistent dev server. +- No requirement to make physical-device LAN mode the first delivered slice, but it is required before declaring the workflow complete. +- No replacement of Turborepo as the monorepo task runner. + +--- + +## Key Decisions + +- Portless should be part of the Turborepo development workflow rather than a separate optional convention because the primary value is predictable service discovery for agents and worktrees. +- Expo is a readiness requirement because PackRat's local development surface includes the mobile app, not just browser apps. +- Physical-device support can follow simulator support, but it should remain in the same requirements scope so it does not disappear after the easier web/API work lands. +- Direct underlying dev commands should remain available as a bypass path because contributors may need to debug Portless, Turborepo, or framework-specific server behavior independently. + +--- + +## Dependencies / Assumptions + +- The Turborepo branch remains the base for this work. +- Portless supports Bun workspaces and Turborepo-style package scripts. +- Portless documentation says Expo and React Native receive injected port handling, and LAN mode changes Expo host behavior for device access. +- Expo currently consumes its API base URL from public development environment configuration. +- Cloudflare Worker services need validation because documentation confidence is lower than for Next.js and Expo. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R6, R8][Technical] What is the cleanest way for Expo to receive the active named API URL in simulator mode without weakening existing environment validation? +- [Affects R10][Needs research] Which exact iOS and Android networking configuration is required for Portless LAN mode on physical devices? +- [Affects R13][Needs research] Do the Cloudflare Worker services accept Portless-injected ports automatically, or do they require explicit local dev flags? +- [Affects R15][Technical] Should stale-process recovery be documented as a manual command, wrapped in a repo script, or both? diff --git a/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md b/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md new file mode 100644 index 0000000000..4426aa6844 --- /dev/null +++ b/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md @@ -0,0 +1,295 @@ +--- +date: 2026-06-01 +type: feat +status: active +title: "feat: Portless Turborepo dev workflow (named local URLs for 50+ parallel agents)" +origin: docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md +plan_depth: deep +base_branch: feat/turbo-l4 +worktree: .worktrees/explore/portless-packrat +--- + +# feat: Portless Turborepo Dev Workflow + +Adopt [portless](https://github.com/vercel-labs/portless) as PackRat's local-dev front door so services get stable, per-worktree named URLs instead of fixed colliding ports — fixing the port-collision storm from 50+ parallel host-side agents, and extending named-URL targeting to Expo (simulator first, physical-device LAN before "complete"). + +Origin requirements: `docs/brainstorms/2026-05-22-portless-turborepo-dev-workflow-requirements.md` (full canonical scope, R1–R15). + +--- + +## Problem Frame + +PackRat is routinely developed by 50+ AI coding agents (plus their subagents) running concurrently in sibling host worktrees, with Docker used for specific services but no agent sandboxing. Every agent that boots a dev server reaches for the same hardcoded default ports — `apps/web` (`next dev --port 3001`), `apps/admin` (`next dev --port 3002`), `packages/api` and `packages/mcp` (`wrangler dev` → `:8787`), Expo Metro (`:8081`). The second agent to start any server collides with the first, or worse, silently shares a port. The blast radius scales with agent count — the opposite of what PackRat wants as it leans into parallel-agent development. + +The Turborepo migration (this `feat/turbo-l4` base) is the natural integration point: portless delegates to `turbo run` when `turbo.json` is present and injects per-process `PORT`/`HOST`/`PORTLESS_URL`. Expo raises the bar beyond browser-only dev — the mobile app consumes a configured API URL, and local readiness is incomplete if web/API have named URLs but Expo still depends on manual API-URL juggling (`getApiBaseUrl()`'s `10.0.2.2` swap, physical-device LAN IPs). + +A second pain rides along: bare `localhost` gives no real HTTPS, so secure-context behavior (cookies, service workers, webauthn) is fudged in dev. Portless serves HTTPS via a local CA by default. + +--- + +## Actors + +- A1. **Human developer** (origin A1): starts local workflows, may run Expo on simulator or physical devices. +- A2. **Coding agent / subagent** (origin A2): starts and tests services in a worktree without manual port coordination. +- A3. **PackRat local services** (origin A3): web apps, Cloudflare Worker services (API, MCP), Expo Metro. +- A4. **Mobile device or simulator** (origin A4): runs the Expo app, connects to the local API during development. + +--- + +## Key Flows (carried from origin) + +- **F1. Multi-agent web/API development** (origin F1; covered by U2, U3, U4, U6auth) — services start through the Turbo dev workflow, each gets a stable named URL, agents use names not ports; multiple worktrees run simultaneously without collisions. +- **F2. Expo simulator local API development** (origin F2; covered by U7, U8) — local API starts with a named portless URL, Expo receives a matching local API URL, simulator/emulator makes authenticated calls. +- **F3. Expo physical-device local API development** (origin F3; covered by U9) — portless LAN mode exposes the local API through a device-reachable name, Expo uses it, iOS/Android networking requirements satisfied. + +--- + +## Key Technical Decisions + +- **Integrate with Turbo, not before it.** Portless's Turbo delegation (`turbo run` + `PORT`/`HOST`/`PORTLESS_URL` injection) is the cleanest seam, and this base already has `turbo.json`. (origin Key Decisions; advances R2) +- **`$PORT` adoption is the load-bearing change, not the install.** Port collisions only disappear once dev scripts stop hardcoding ports. Tool behavior differs: + - **Next.js reads `$PORT` natively** → drop the `--port 3001/3002` flags from `apps/web` and `apps/admin`; `guides`/`landing`/`trails` are already bare `next dev`. + - **Wrangler does NOT read `$PORT`** → change `wrangler dev -e=dev` to `wrangler dev -e=dev --port ${PORT:-8787}` for `packages/api` and `packages/mcp` (fallback preserves the direct-run contract). + - **Expo `--port`/`--host` are auto-injected by portless** → no script change needed for Metro. +- **Stable base names + branch-prefixed worktrees.** Root `portless.json` `apps` map pins canonical names (`web`, `admin`, `guides`, `landing`, `trails`, `api`, `mcp`); portless auto-prepends the branch as a subdomain in linked worktrees, so 50 agents get distinct URLs with zero per-agent config. (advances R1, R4, R5; AE1) +- **Wire auth for portless origins now** (user decision). Better-auth `baseURL`/`trustedOrigins` and the Elysia `cors()` allowlist must accept the `.localhost` HTTPS origins *including per-worktree branch subdomains*, or login breaks under portless. Today these pin `http://localhost:8787` (`packages/api/src/auth/auth.config.ts`). +- **Keep native on its own rails, extended by LAN mode.** `getApiBaseUrl()`'s `10.0.2.2`/device-IP path stays; portless LAN mode (`PORTLESS_LAN=1`, which omits the loopback `HOST` override for Expo) is what makes a device-reachable named URL possible. `.localhost` is loopback-only and is NOT used by devices. (advances R8, R10, R11) +- **Preserve a bypass path** (R3, R14): every dev script must remain runnable directly (the `${PORT:-default}` fallbacks ensure this). +- **Leave `db.localtest.me` alone.** The local Neon HTTP proxy is a separate, already-solved concern; the spike (U1) only *validates* it still works under portless. + +--- + +## Output / Config Surface + +New or modified config the plan introduces: + +```text +portless.json # NEW — root: apps name map, wildcard/worktree config +package.json # MOD — root: dev orchestration entry for portless +apps/web/package.json # MOD — drop --port 3001 +apps/admin/package.json # MOD — drop --port 3002 +packages/api/package.json # MOD — wrangler dev --port ${PORT:-8787} +packages/mcp/package.json # MOD — wrangler dev --port ${PORT:-8787} +packages/api/src/auth/auth.config.ts # MOD — trustedOrigins/baseURL accept portless origins +packages/api/src/index.ts # MOD — cors() allowlist for portless origins +apps/web/lib/{api,auth-client}.ts # MOD — derive origin (PORTLESS_URL) not pinned :8787 +scripts/portless-clean.ts (or .sh) # NEW — stale-process recovery wrapper +docs/ (dev workflow doc) # NEW/MOD — normal + simulator + LAN + recovery +``` + +The tree is a scope declaration, not a constraint — per-unit `**Files:**` are authoritative. + +--- + +## Implementation Units + +### U1. Coexistence spike: portless :443 + local CA vs wrangler & Neon proxy + +- **Goal:** De-risk before any rollout — prove portless's `:443` bind, sudo elevation, and local CA (`~/.portless/ca.pem`, injected as `NODE_EXTRA_CA_CERTS`) do not break `wrangler dev`, Worker-to-Worker service bindings, or the `db.localtest.me` Neon HTTP proxy. +- **Requirements:** R13; AE5 (worker reachable via named URL, not a stale process) +- **Dependencies:** none (runs first) +- **Files:** none committed — findings recorded inline in this plan's verification and, if notable, a `docs/solutions/` note +- **Approach:** Start portless, run `packages/api` through it, confirm: (a) the worker responds at its named URL; (b) `db.localtest.me` DB calls still resolve (host special-casing in `packages/api/src/db/index.ts` and `src/index.ts` unaffected); (c) no CA/TLS conflict with wrangler's own inspector/dev TLS. Document the `--no-tls` fallback if a conflict surfaces. +- **Execution note:** This is a validation spike — capture results before proceeding to U3+. If a hard conflict exists, surface it before rollout rather than coding around it. +- **Test scenarios:** + - Covers AE5. Given portless is running, when an agent requests the API's named URL, the request reaches the intended worker (verify via a known route's response), not a stale/unrelated process. + - Given portless has installed its CA and bound `:443`, when `wrangler dev` starts and serves a DB-backed route, the `db.localtest.me` proxy call succeeds (row returned). + - Given two workers (api + mcp) under portless, when one calls the other via service binding, the call succeeds. +- **Verification:** API + MCP reachable by name; a DB-backed route returns real data; no TLS/CA errors in wrangler output. Conflicts, if any, documented with mitigation. +- **Spike result (2026-06-01) — mechanism verified; end-to-end serving gated on proxy ownership; a separate wrangler blocker found:** + - portless 0.13.1 installs without sudo (mise node prefix). After a one-time `setcap cap_net_bind_service` + CA trust, it assigns stable worktree-prefixed URLs (observed: `https://portless-packrat.packrat-web-app.localhost`) and injects `PORT`/`HOST`/`PORTLESS_URL`/`NODE_EXTRA_CA_CERTS`. Verified live: `apps/web` started via `portless run`, Next honored the injected `PORT` (bound `:4456`), the backend served real responses (`307`), and the route registered correctly. + - **END-TO-END VERIFIED (user-context proxy):** with a user-owned proxy, `apps/web` via `portless run` served a real app response through the proxy — `GET https://portless-packrat.packrat-web-app.localhost:1355/` → `HTTP/2 307`, `x-portless: 1`, `location: /auth` (the app's genuine unauthenticated redirect). The full chain works: portless.json naming + worktree prefix + `$PORT` injection + proxy → backend. + - **Proxy-ownership pitfall (caused every proxied 404 during the spike):** the `:443` daemon had been started **as root** (via the initial sudo CA step) while dev servers register routes as the non-root user; root's `~/.portless` state is disjoint from the user's, so the root proxy answers (`x-portless: 1`) but **404s every user-registered backend**. **The proxy MUST run as the same user as the dev servers. Never `sudo portless proxy start`.** + - **`:443` vs `:1355` — a real bootstrap fork (portless 0.13.1):** the `setcap cap_net_bind_service` capability genuinely lets the user's node bind `:443` (verified: a bare `node ... listen(443)` succeeds), but **portless 0.13.1 ignores the cap and hardcodes a sudo elevation for `:443`**. Consequences: + - **No-sudo path (recommended default):** let portless fall back to a **user-owned `:1355`** proxy. Works end-to-end, zero sudo, fully solves the 50-agent collision problem; cost is `:1355` in URLs. + - **Clean-`:443` path:** requires sudo at proxy start. To keep it user-context (avoid the split-brain) *and* clean, options to evaluate: `sudo -E portless proxy start` (preserves `HOME` so root proxy shares the user's `~/.portless` state), a passwordless-sudo rule scoped to `portless proxy start`, or `portless service install` (verify it runs as the user). The plain `setcap` route does NOT achieve no-sudo `:443` with this portless version. + - **Blocker (independent of portless, but gates the agent workflow):** `packages/api` `wrangler dev` runs in **remote mode** because of its `containers` + `durable_objects` bindings, so in non-interactive/agent contexts it fails with *"More than one account available"* unless `CLOUDFLARE_ACCOUNT_ID` is **exported into the process env**. The value exists in `packages/api/.dev.vars` and root `.env.local` but is not auto-exported to wrangler's CLI. This bites every one of the 50+ agents regardless of portless. Resolution moved into U3. Secondary concern: 50 agents each open a *remote* CF dev session (possible account-side limits) — see Risk table. + - `db.localtest.me` coexistence was not exercised: the worktree's `.dev.vars` points NEON at cloud Neon, not the local proxy, so that sub-risk only applies if someone switches NEON to `db.localtest.me`. + +### U2. Install portless + host bootstrap (privileged setup) + root naming config + +- **Goal:** Add portless to the repo with stable canonical service names and worktree-aware routing, plus a one-time host-bootstrap path that needs no sudo after first run. +- **Requirements:** R1, R4, R5 +- **Dependencies:** U1 +- **Files:** `portless.json` (new), root `package.json` (dev orchestration entry + `portless:setup` script + non-privileged postinstall readiness check), `scripts/portless-setup.sh` (new, host bootstrap), `.gitignore` (portless state if any), `docs/` (brief "what portless is" note — expanded in U10) +- **Approach:** Root `portless.json` with an `apps` map binding `apps/web`→`web`, `apps/admin`→`admin`, `apps/guides`→`guides`, `apps/landing`→`landing`, `apps/trails`→`trails`, `packages/api`→`api`, `packages/mcp`→`mcp`. Rely on portless auto-discovery of Bun workspaces for anything unlisted, and on branch-prefixed subdomains for linked worktrees (enable `--wildcard` / `PORTLESS_WILDCARD=1` so worktree subdomains resolve). Confirm project-name inference (`@packrat` scope) yields the intended base. +- **Host bootstrap (verified CLI, run once per machine):** the privileged setup is `sudo setcap 'cap_net_bind_service=+ep' "$(readlink -f "$(command -v node)")"` (node binds `:443` without sudo thereafter) + `portless trust` (CA into system trust store) + optionally `portless service install` (proxy auto-starts on OS boot — recommended for the 50-agent host). NOTE: `portless ca install` is **not** a command; the CA is trusted by `portless trust` or automatically on first `portless proxy start`. Package this as `bun run portless:setup` (idempotent) rather than a `postinstall` — privileged ops must never run in `postinstall`/CI/non-TTY. The postinstall step is limited to a **non-privileged readiness check** that prints "run `bun run portless:setup`" when the cap/CA are absent. **mise gotcha:** node lives under `~/.local/share/mise/...`, so a node version bump moves the binary and silently drops the `setcap` (portless then falls back to `:1355`) — the setup script must be re-runnable and the readiness check should detect this. `setcap` on node lets any node process bind low ports — note this deliberate loosening in the script. +- **Docker/static routes:** `portless alias ` registers a static route for a non-portless-managed process (e.g. the Neon proxy container on `:4444`) if it ever needs a named URL — not required for default cloud-Neon config, but available. +- **Patterns to follow:** Bun workspace layout in root `package.json` `workspaces`; existing per-app script naming; existing `scripts/` conventions. +- **Test scenarios:** + - Given `portless.json` is present, when portless starts in the main worktree, each app is reachable at its canonical `..localhost` URL. + - Covers AE1 (partial). Given a second worktree on branch `X`, when portless starts there, services resolve at `x...localhost` distinct from main. +- **Verification:** Named URLs resolve for all seven services in the main worktree; a linked worktree yields branch-prefixed URLs. + +### U3. Make dev scripts honor injected `$PORT` (Next + wrangler) with bypass fallback + +- **Goal:** Eliminate hardcoded ports so per-process `PORT` injection actually prevents collisions, without breaking direct `bun run dev`. +- **Requirements:** R2, R3, R12, R14; AE2 +- **Dependencies:** U2 +- **Files:** `apps/web/package.json` (drop `--port 3001`), `apps/admin/package.json` (drop `--port 3002`), `packages/api/package.json` (`wrangler dev -e=dev --port ${PORT:-8787}`), `packages/mcp/package.json` (`wrangler dev -e dev --port ${PORT:-8787}`), wrangler account-id wiring (see below) +- **Approach:** Next reads `$PORT` natively, so removing the `--port` flag lets injection work and falls back to Next's default when run directly. Wrangler ignores `$PORT`, so use shell default expansion `${PORT:-8787}` — portless injects `PORT`; a direct run uses `8787`. Verify Turbo dependency ordering is preserved when portless delegates to `turbo run`. +- **Non-interactive wrangler account selection (from U1 spike):** `packages/api` runs `wrangler dev` in **remote mode** (its `containers`/`durable_objects` bindings force it), so in agent/non-interactive contexts it needs `CLOUDFLARE_ACCOUNT_ID` in the **process env**. It currently sits in `.dev.vars`/`.env.local` (worker-runtime + Bun-loaded) but is not exported to wrangler's CLI. Fix by pinning `account_id` in `packages/api/wrangler.jsonc` (cleanest — deterministic, no env reliance) **or** exporting `CLOUDFLARE_ACCOUNT_ID` ahead of `wrangler dev` in the dev script. Pinning in `wrangler.jsonc` is preferred so all 50 agents behave identically. Apply the same to `packages/mcp` if it is also remote. +- **Patterns to follow:** existing bare `next dev` in `apps/guides`/`landing`/`trails` (already PORT-friendly); existing `wrangler.jsonc` config blocks. +- **Test scenarios:** + - Covers AE2. Given a contributor runs `bun run --cwd apps/web dev` directly (no portless), the app still starts on its default port via fallback. + - Covers AE1 (partial). Given two worktrees each run the API through portless, both `wrangler dev` processes bind distinct injected ports with no "address in use" error. + - Given `apps/web` runs under portless, when injected `PORT=47xx`, Next serves on that port and the named URL proxies to it. + - Given a non-interactive agent shell, when `wrangler dev` starts for `packages/api`, account selection succeeds (no "More than one account available" error) because `account_id` is pinned / exported. +- **Verification:** Direct runs work on defaults; portless runs get injected ports; no collisions across two simultaneous worktrees; `wrangler dev` starts cleanly in a non-interactive shell; `turbo run dev` ordering intact. + +### U4. Agent URL discovery via `PORTLESS_URL` + +- **Goal:** Let agents/subagents obtain their own instance URL without scraping another process's terminal output. +- **Requirements:** R6; AE1 +- **Dependencies:** U3 +- **Files:** `docs/` (agent-facing note), optionally a tiny helper script under `scripts/` that prints resolved service URLs for the current worktree +- **Approach:** Document that each dev process receives `PORTLESS_URL` (its public URL) and that branch-prefixed names are derivable from the current branch. Provide a one-shot command/script an agent can call to print the active named URLs for this worktree. +- **Test scenarios:** + - Covers AE1. Given an agent starts services in a worktree, when it reads `PORTLESS_URL` / runs the helper, it gets the correct reachable URL for its instance without reading another agent's output. +- **Verification:** The documented discovery path returns the correct per-worktree URL in a fresh worktree. + +### U5. Wire better-auth + CORS for portless origins (incl. per-worktree wildcards) + +- **Goal:** Make login work under portless by accepting `.localhost` HTTPS origins and per-worktree branch subdomains. +- **Requirements:** R8 (support), R12; AE1 (auth'd flows) +- **Dependencies:** U3 +- **Files:** `packages/api/src/auth/auth.config.ts` (`baseURL`, `trustedOrigins`), `packages/api/src/index.ts` (Elysia `cors()` config), `packages/api/src/utils/env-validation.ts` (`BETTER_AUTH_URL` default), `packages/api/src/utils/openapi.ts` (dev server URL) +- **Approach:** Extend `trustedOrigins` and the CORS allowlist to include the portless API origin(s). Because worktrees produce `*.api..localhost`, support a wildcard/suffix match for trusted origins in local/dev mode rather than a single literal. Keep `packrat://` (native scheme) and the existing `localhost:8787` for the bypass path. Drive values from env (`BETTER_AUTH_URL` / a portless-aware origin) so production config is untouched. Gate the wildcard relaxation to development to avoid weakening production auth. +- **Patterns to follow:** existing `trustedOrigins` array and `cors()` usage; env-driven config in `env-validation.ts`. Cross-origin auth learnings in `docs/solutions/` (web cross-origin auth + local neon-proxy note). +- **Test scenarios:** + - Given dev mode and a portless API origin, when the web app posts to `auth.login`, the request passes CORS preflight and sets the session cookie. + - Given a per-worktree origin `feat-x.api..localhost`, when auth is attempted, the wildcard trusted-origin match accepts it. + - Given production config, when a non-trusted origin calls auth, it is rejected (wildcard relaxation does not leak to prod). + - Given the bypass path (`localhost:8787`), auth still works unchanged. +- **Verification:** Login succeeds end-to-end under portless in main and a linked worktree; production origin behavior unchanged; bypass path intact. + +### U6. Web app origin derivation (drop pinned `:8787`) + +- **Goal:** Web apps target the portless API origin instead of a hardcoded `localhost:8787`, so a per-worktree dynamic API origin doesn't break calls. +- **Requirements:** R12; AE1 +- **Dependencies:** U5 +- **Files:** `apps/web/lib/api.ts`, `apps/web/lib/auth-client.ts`, `apps/web/app/auth/page.tsx`, `apps/web/components/screens/ai-screen.tsx` (and `apps/admin` equivalents if pinned) +- **Approach:** Derive the API base from `NEXT_PUBLIC_API_URL` populated from `PORTLESS_URL`/portless config in dev, keeping the `?? 'http://localhost:8787'` fallback for direct runs. No client redesign — only origin selection changes. +- **Patterns to follow:** existing `NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'` pattern; `@packrat/api-client` `createApiClient` (`baseUrl` = same origin when proxied). +- **Test scenarios:** + - Given portless assigns a per-worktree API origin, when the web app makes an API call, it resolves against that origin (not a stale `:8787`) and succeeds. + - Given a direct run without portless, the fallback origin is used and calls succeed. +- **Verification:** Web → API calls succeed under portless in a linked worktree; direct-run fallback unaffected. + +### U7. Expo simulator targets the named local API + +- **Goal:** Expo on simulator/emulator hits the local API by its named portless URL with no manual port editing — part of the first usable slice. +- **Requirements:** R8, R9, R11; AE3 +- **Dependencies:** U5 +- **Files:** `apps/expo/lib/api/getBaseUrl.ts` (reconcile `10.0.2.2` swap with named URL), `packages/env/src/expo-client.ts` (`EXPO_PUBLIC_API_URL`), `apps/expo/.env*`/env wiring +- **Approach:** Set `EXPO_PUBLIC_API_URL` to the portless API URL for simulator/emulator runs. Reconcile `getApiBaseUrl()`: on iOS simulator the host loopback is reachable; on Android emulator keep the `10.0.2.2` mapping (or use portless LAN host). No broad API-client redesign (R11) — only base-URL selection. Confirm Metro's portless `--port`/`--host` auto-injection doesn't conflict with the API URL config (Metro ≠ API). +- **Patterns to follow:** existing `getApiBaseUrl()` platform branch; `clientEnvs.EXPO_PUBLIC_API_URL` usage in `rpcTransport.ts`/`client.ts`. +- **Test scenarios:** + - Covers AE3. Given the API runs through portless, when the Expo app runs in iOS simulator, authenticated API calls reach the local API without editing a hardcoded port. + - Given Android emulator, when the app calls the API, the `10.0.2.2`/LAN mapping resolves to the running local API. + - Given no portless (direct run), `EXPO_PUBLIC_API_URL` fallback still works. +- **Verification:** Simulator and emulator both make successful authenticated calls to the local API via the named/derived URL; direct-run path intact. + +### U8. Expo simulator auth parity check + +- **Goal:** Ensure auth works from Expo simulator against the portless API (cookies/origins differ from web). +- **Requirements:** R8, R9; AE3 +- **Dependencies:** U5, U7 +- **Files:** none expected beyond U5/U7; otherwise minor `apps/expo` auth wiring +- **Approach:** Verify the native `packrat://` scheme + portless API origin combination authenticates. Confirm `trustedOrigins` from U5 covers the native case. +- **Test scenarios:** + - Given the simulator app and portless API, when the user logs in, the session is established and protected routes succeed. +- **Verification:** Login + a protected call succeed from simulator. + +### U9. Expo physical-device LAN mode + +- **Goal:** A physical phone/tablet on the same network reaches the local API via a LAN-reachable named URL — required before the workflow is "complete." (The risky unit.) +- **Requirements:** R10; AE4 +- **Dependencies:** U7 +- **Files:** `apps/expo` env/config for LAN host, `docs/` (LAN setup), possibly `apps/expo/lib/api/getBaseUrl.ts` +- **Approach:** Enable portless LAN mode (`PORTLESS_LAN=1`) so services bind LAN-reachably and Expo's `HOST` override is omitted. Determine the exact iOS and Android networking config needed (App Transport Security / cleartext or trusted-cert handling for the portless CA on device; same-subnet requirement). Point `EXPO_PUBLIC_API_URL` at the LAN-reachable name/IP for device runs. +- **Execution note:** Treat as a spike-then-implement — device networking + the portless CA on a physical device is the least-documented area; validate reachability before finalizing config. +- **Test scenarios:** + - Covers AE4. Given portless LAN mode and a phone on the same network, when the Expo app runs on the phone, it reaches the local API through a LAN-reachable URL. + - Given iOS device, the portless CA / ATS configuration permits the API call (no cert/cleartext rejection). + - Given Android device, the network-security-config permits the API call. +- **Verification:** A physical iOS and Android device each complete an authenticated API call against the LAN local API. + +### U10. Stale-process recovery + documentation + +- **Goal:** Provide cleanup/recovery for abandoned agent sessions and document the full workflow. +- **Requirements:** R7, R15 +- **Dependencies:** U2, U3, U7, U9 +- **Files:** `scripts/portless-clean.ts` or `.sh` (wrapper over `portless clean`/proxy stop), `docs/` workflow doc (normal, simulator, physical-device LAN, recovery), root `package.json` script entry +- **Approach:** Wrap `portless clean` / proxy restart with PackRat-friendly defaults and document when to use it (stale `/etc/hosts` entries, abandoned worktree routes, CA reset). Document the normal multi-agent workflow, Expo simulator workflow, physical-device LAN workflow, and recovery steps. Note the no-silent-caps expectation: if 50 concurrent agents hit a portless limit, surface it. +- **Test scenarios:** + - Given stale routes from an abandoned worktree, when the cleanup script runs, routes/processes are cleared and a fresh start succeeds. + - `Test expectation: docs unit — validated by following the written steps end-to-end in a clean worktree.` +- **Verification:** Cleanup script recovers a wedged local state; a new contributor can follow the docs to run web, simulator, and device workflows. + +--- + +## Scope Boundaries + +### Deferred for later (origin scope sequencing) + +- Physical-device LAN (U9) may land after the simulator slice (U7/U8), but stays in this plan's scope so it doesn't disappear (origin Key Decisions). + +### Deferred to Follow-Up Work (plan-local) + +- Remote Turbo cache configuration (orthogonal to portless). +- Migrating Turbo itself onto `development` (separate effort; this plan rides the `feat/turbo-l4` base). + +### Out of scope (origin) + +- Production, staging, and deployed preview URL strategy — local development only. +- Broad rewrite of the Expo API client or env system (only reliable local URL selection; R11). +- Portless-managing packages that don't run a persistent dev server. +- Replacing Turborepo as the task runner. +- Re-routing or replacing the `db.localtest.me` Neon proxy (validated only, U1). + +--- + +## Dependencies / Assumptions + +- **Base:** `feat/turbo-l4` (Turbo + Elysia, 0 behind `development`). Work happens in the `.worktrees/explore/portless-packrat` worktree, now refreshed onto this base. `bun install` is required before implementation (`node_modules` currently absent; needs `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN`). +- Agents run host-side sharing loopback/`/etc/hosts` (no sandboxing) — confirmed; this is portless's intended model. Docker is used for specific services (e.g. Neon proxy), not agent isolation; do not dockerize the web/Worker dev servers without revisiting (containers can't resolve host `.localhost`). +- Portless supports Bun workspaces and delegates to `turbo run` when `turbo.json` is present; it injects `PORT`/`HOST`/`PORTLESS_URL`. +- Next.js reads `$PORT`; wrangler does not (needs `--port`); Expo `--port`/`--host` are auto-injected. +- Portless binds `:443` with sudo elevation and a local CA (`NODE_EXTRA_CA_CERTS`); a `--no-tls` fallback exists if it conflicts with wrangler (U1: no conflict observed). After one-time host bootstrap (setcap + `portless trust`, optionally `portless service install`), no further sudo is needed per worktree/agent. +- `packages/api` `wrangler dev` runs in **remote mode** (forced by `containers`/`durable_objects`) and requires a deterministic CF account selection in non-interactive shells (U3 pins `account_id`). This is a PackRat dev-setup fact independent of portless. +- Expo consumes its API base URL from `EXPO_PUBLIC_API_URL` (`packages/env/src/expo-client.ts`). + +--- + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Portless `:443`/CA conflicts with wrangler dev TLS or breaks `db.localtest.me` | ~~Medium~~ Low (U1: GREEN) | High | U1 spike done — no conflict observed; `--no-tls` fallback remains documented | +| Wrangler `dev` fails for `packages/api` in non-interactive agent shells (remote mode + multi-account, no `CLOUDFLARE_ACCOUNT_ID` exported) | High (confirmed in U1) | High | Pin `account_id` in `wrangler.jsonc` (U3); applies with or without portless | +| 50 agents each open a *remote* CF dev session (containers/DO force remote) → possible account-side session limits / quota | Medium | Medium | Monitor; investigate whether `packages/api` can run container-less local dev for agents; document in U10 | +| `setcap` on mise node silently dropped after a node version bump → portless falls back to `:1355` | Medium | Low | Idempotent `portless:setup` + postinstall readiness check (U2) | +| Proxy started as **root** (via sudo) split-brains from user-context route registrations → answers but 404s every backend (hit during U1) | High if sudo-started | High | Proxy must run as the dev user; `setcap` enables `:443` without root; never `sudo portless proxy start`; `portless:setup` + docs enforce this (U2/U10) | +| Per-worktree wildcard trusted-origins weakens production auth | Low | High | Gate wildcard relaxation to dev mode only (U5); production origin test | +| Physical-device LAN + portless CA on iOS/Android is under-documented | High | Medium | U9 treated as spike-then-implement; validate device reachability first | +| `$PORT` change breaks Turbo dependency ordering | Low | Medium | Verify `turbo run dev` ordering in U3 | +| 50 concurrent agents hit an undocumented portless daemon limit | Low | Medium | No-silent-caps: surface/log limits in U10 docs | + +--- + +## Phased Delivery + +- **Phase 1 — De-risk & foundation:** U1 (spike), U2 (install/config), U3 (`$PORT`). +- **Phase 2 — Agents & web:** U4 (discovery), U5 (auth/CORS), U6 (web origin). +- **Phase 3 — Expo:** U7 (simulator), U8 (simulator auth), U9 (physical-device LAN). +- **Phase 4 — Harden & document:** U10 (recovery + docs). + +--- + +## Success Criteria (from origin) + +- 50+ agents/worktrees run local services with zero port-collision failures and no manual port assignment as the normal path. +- Expo targets the local API in simulator/emulator mode, with a working+documented physical-device path. +- A downstream implementer can execute each unit without inventing product behavior. +- The workflow stays understandable for humans who don't use subagents heavily (bypass path preserved). From c2d55d15a6c907ddd3e2226392dd6ae4bdac1bd1 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:13:14 -0600 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20feat(dev):=20add=20portless=20n?= =?UTF-8?q?aming=20config=20+=20host=20bootstrap=20(U2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portless.json maps workspace apps to stable names - scripts/portless-setup.sh: idempotent one-time host bootstrap (CA trust) - scripts/portless-check.ts: non-privileged postinstall readiness reminder - root package.json: portless:setup/portless:check scripts + portless devDep - .gitignore: .portless/ --- .gitignore | 3 +++ package.json | 5 +++- portless.json | 12 ++++++++++ scripts/portless-check.ts | 39 +++++++++++++++++++++++++++++++ scripts/portless-setup.sh | 48 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 portless.json create mode 100644 scripts/portless-check.ts create mode 100755 scripts/portless-setup.sh diff --git a/.gitignore b/.gitignore index b1dcf6f57f..c788b4be64 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ apps/guides/public/og/ .worktrees/ .worktrees .turbo/ + +# portless (local-dev proxy; state lives in ~/.portless, this is defensive) +.portless/ diff --git a/package.json b/package.json index 707070d950..f6fe032726 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "format": "biome format --write", "format:package-json": "bun scripts/format/sort-package-json.ts", "preinstall": "bun run configure:deps", - "postinstall": "bun run lefthook && bun run env", + "postinstall": "bun run lefthook && bun run env && bun run portless:check", "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", @@ -42,6 +42,8 @@ "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", "mcp:deploy": "bun run --cwd packages/mcp deploy", + "portless:check": "bun run scripts/portless-check.ts", + "portless:setup": "bash scripts/portless-setup.sh", "test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts", "test:api-client:types": "vitest run --config packages/api-client/vitest.config.ts", "test:e2e:android": "bash .github/scripts/e2e.sh android", @@ -72,6 +74,7 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "lefthook": "^1.11.14", + "portless": "^0.13.1", "semver": "catalog:", "sort-package-json": "^3.6.1", "turbo": "^2.5.0" diff --git a/portless.json b/portless.json new file mode 100644 index 0000000000..e2583dc816 --- /dev/null +++ b/portless.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://portless.sh/schema.json", + "apps": { + "apps/web": { "name": "web" }, + "apps/admin": { "name": "admin" }, + "apps/guides": { "name": "guides" }, + "apps/landing": { "name": "landing" }, + "apps/trails": { "name": "trails" }, + "packages/api": { "name": "api" }, + "packages/mcp": { "name": "mcp" } + } +} diff --git a/scripts/portless-check.ts b/scripts/portless-check.ts new file mode 100644 index 0000000000..355658d972 --- /dev/null +++ b/scripts/portless-check.ts @@ -0,0 +1,39 @@ +// Non-privileged portless readiness check, run from postinstall. +// +// Stays silent unless the dev has opted into portless (the `portless` binary is +// present) AND the local CA has not been generated/trusted yet. Never fails the +// install — it only prints a one-line reminder. + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +function has(cmd: string): boolean { + try { + execSync(`command -v ${cmd}`, { stdio: 'ignore', shell: '/bin/bash' }); + return true; + } catch { + return false; + } +} + +try { + // Only relevant if the dev is actually using portless. + if (!has('portless')) { + process.exit(0); + } + + // ~/.portless/ca.pem appears once `portless trust` (or a first proxy start) has run. + const caReady = existsSync(join(homedir(), '.portless', 'ca.pem')); + + if (!caReady) { + console.warn('\n⚠️ portless is installed but its local CA is not set up yet.'); + console.warn(' One-time fix: bun run portless:setup'); + console.warn( + ' Then start dev with: portless (user-owned proxy — never run it under sudo)\n', + ); + } +} catch { + // Best-effort only — never block install. +} diff --git a/scripts/portless-setup.sh b/scripts/portless-setup.sh new file mode 100755 index 0000000000..b31f4a57d0 --- /dev/null +++ b/scripts/portless-setup.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# One-time-per-host portless setup for the 50+ parallel-agent workflow. +# Idempotent — safe to re-run. +# +# Two rules this script encodes, learned the hard way (see docs/plans U1 spike): +# 1. The proxy MUST run as the SAME user that runs the dev servers. A root-owned +# proxy (e.g. from `sudo portless proxy start`) uses root's ~/.portless state, +# which is disjoint from the user's — so it answers requests but 404s every +# backend your dev servers register. NEVER `sudo portless proxy start`. +# 2. portless 0.13.1 hardcodes a sudo elevation for :443 and IGNORES the +# cap_net_bind_service capability. So plain setcap does NOT buy you a no-sudo +# :443 with this version (the cap works for node generally — portless just +# doesn't use it). Pick a port strategy below. +# +set -euo pipefail + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "On macOS: run 'portless trust', then start the proxy as your user." + echo "Do not run the proxy under sudo (state split-brain → 404s)." +fi + +# 1. Trust the portless local CA so browsers accept its HTTPS certs. +# This (writing the system trust store) is the only step that may prompt for sudo. +echo "→ trusting portless local CA" +portless trust + +# 2. Port strategy — choose one: +# +# (a) DEFAULT, no sudo: let portless run a user-owned proxy on :1355. +# Fully solves port collisions for parallel agents; URLs carry ':1355'. +# Just run `portless` / `portless run` — it falls back to :1355 automatically. +# +# (b) Clean :443 URLs: requires sudo at proxy start AND must stay user-context. +# Use ONE of these (do not use a bare `sudo portless proxy start` — that +# split-brains as root): +# sudo -E portless proxy start # -E preserves HOME so it shares ~/.portless +# # or scope a passwordless-sudo rule to `portless proxy start` +# # or: portless service install (verify the service runs as your user) +# +# (Optional) capability for node — harmless, lets node itself bind low ports, and +# future portless versions may honor it. Does NOT give portless 0.13.1 no-sudo :443. +# NODE_BIN="$(readlink -f "$(command -v node)")" +# sudo setcap 'cap_net_bind_service=+ep' "$NODE_BIN" +# # NOTE: mise moves the node binary on version bumps, dropping this — re-run then. + +echo "✓ portless CA trusted. Start dev with: portless (user-owned :1355 by default)" +echo " For clean :443 URLs, see option (b) in this script." From 4920687c953a3459b9959820f66dfc8297ba7fac Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:13:14 -0600 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20feat(dev):=20inject=20$PORT=20i?= =?UTF-8?q?nto=20dev=20servers=20+=20pin=20wrangler=20account=5Fid=20(U3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/admin: next dev --port ${PORT:-3001/3002} (portless injection wins, distinct fallback) - api/mcp: wrangler dev --port ${PORT:-8787} - api/mcp wrangler.jsonc: pin account_id so remote-mode dev works in non-interactive agent shells --- apps/admin/package.json | 2 +- apps/web/package.json | 2 +- packages/api/package.json | 2 +- packages/api/wrangler.jsonc | 3 +++ packages/mcp/package.json | 2 +- packages/mcp/wrangler.jsonc | 3 +++ 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index a1e728920c..d67cb93019 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -6,7 +6,7 @@ "build": "next build", "check-types": "tsc --noEmit", "clean": "bunx rimraf node_modules .next out", - "dev": "next dev --port 3002", + "dev": "next dev --port ${PORT:-3002}", "doctor:react": "bunx react-doctor", "lint": "next lint", "start": "next start" diff --git a/apps/web/package.json b/apps/web/package.json index 631b065306..fe55b6c226 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "next build", "check-types": "tsc --noEmit", - "dev": "next dev --port 3001", + "dev": "next dev --port ${PORT:-3001}", "doctor:react": "bunx react-doctor", "start": "next start", "type-check": "tsc --noEmit" diff --git a/packages/api/package.json b/packages/api/package.json index f1eb87961b..ccb8b70d21 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,7 +31,7 @@ "db:seed:e2e-user": "bun run ./src/db/seed-e2e-user.ts", "deploy": "wrangler deploy --minify", "deploy:dev": "wrangler deploy --minify -e=dev", - "dev": "wrangler dev -e=dev", + "dev": "wrangler dev -e=dev --port ${PORT:-8787}", "dev:e2e": "bash scripts/e2e-local-start.sh", "dev:e2e:init": "bash scripts/e2e-local-init.sh", "dev:e2e:reset": "bash scripts/e2e-local-stop.sh --volumes && bash scripts/e2e-local-start.sh", diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index d1058c53c8..4352a63884 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -1,6 +1,9 @@ { "$schema": "https://developers.cloudflare.com/schemas/wrangler.json", "name": "packrat-api", + // Pinned so `wrangler dev` (remote mode, forced by containers/DO bindings) selects + // the right account in non-interactive agent shells. Account IDs are not secrets. + "account_id": "a0e238a64b4bb8d9ca35c30149c26893", "main": "src/index.ts", // Elysia 1.4+ CloudflareAdapter requires compatibility_date >= 2025-06-01. "compatibility_date": "2025-06-01", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 966d4e32d9..be68d49fef 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -7,7 +7,7 @@ "_disabled-check-types-reason": "Renamed from check-types so turbo skips this workspace. Strict tsc OOMs (>12 GB heap) on ~50 McpServer.registerTool calls × Eden Treaty's deep App type × strict null analysis. Tracked in #2533. Mcp is still type-checked at deploy via wrangler.", "deploy": "wrangler deploy --minify", "deploy:dev": "wrangler deploy --minify -e dev", - "dev": "wrangler dev -e dev", + "dev": "wrangler dev -e dev --port ${PORT:-8787}", "disabled-check-types": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" diff --git a/packages/mcp/wrangler.jsonc b/packages/mcp/wrangler.jsonc index 2afe42186a..aa6300c4df 100644 --- a/packages/mcp/wrangler.jsonc +++ b/packages/mcp/wrangler.jsonc @@ -1,6 +1,9 @@ { "$schema": "https://developers.cloudflare.com/schemas/wrangler.json", "name": "packrat-mcp", + // Pinned so `wrangler dev` (remote mode, forced by Durable Object bindings) selects + // the right account in non-interactive agent shells. Account IDs are not secrets. + "account_id": "a0e238a64b4bb8d9ca35c30149c26893", "main": "src/index.ts", "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], From 17abf185c61d17b9178836f3cac5546bfccb728c Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:14:37 -0600 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20feat(dev):=20add=20portless:url?= =?UTF-8?q?s=20discovery=20helper=20(U4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prints per-worktree portless URLs for all configured apps so agents discover service URLs without scraping terminal output. Encodes the U1 routing rules: worktree-aware invocation, read PORTLESS_URL, never a root proxy. --- package.json | 1 + scripts/portless-urls.ts | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 scripts/portless-urls.ts diff --git a/package.json b/package.json index f6fe032726..c66878e90e 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "mcp:deploy": "bun run --cwd packages/mcp deploy", "portless:check": "bun run scripts/portless-check.ts", "portless:setup": "bash scripts/portless-setup.sh", + "portless:urls": "bun run scripts/portless-urls.ts", "test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts", "test:api-client:types": "vitest run --config packages/api-client/vitest.config.ts", "test:e2e:android": "bash .github/scripts/e2e.sh android", diff --git a/scripts/portless-urls.ts b/scripts/portless-urls.ts new file mode 100644 index 0000000000..b7b6fddaef --- /dev/null +++ b/scripts/portless-urls.ts @@ -0,0 +1,47 @@ +// Prints the portless URL for each configured app in THIS worktree, so agents and +// humans can discover service URLs without scraping another process's terminal output. +// +// Discovery rules (learned the hard way — see docs/plans U1 spike): +// - Start servers with `portless run` / bare `portless` (worktree-aware), then read +// the injected PORTLESS_URL. NEVER the explicit `portless ` form — it +// skips worktree prefixing and the proxy 404s it. +// - The proxy must run as YOUR user, never under sudo (a root proxy split-brains +// from user-context route registrations and 404s every backend). +// +// Usage: bun run portless:urls + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +type PortlessConfig = { apps?: Record }; + +function configuredNames(): string[] { + const cfg: PortlessConfig = JSON.parse( + readFileSync(new URL('../portless.json', import.meta.url), 'utf8'), + ); + return Object.values(cfg.apps ?? {}) + .map((a) => a.name) + .filter((n): n is string => Boolean(n)); +} + +function urlFor(name: string): string { + try { + return execSync(`portless get ${name}`, { encoding: 'utf8' }).trim(); + } catch { + return '(portless not resolvable — is the proxy running as your user?)'; + } +} + +const names = configuredNames(); +if (names.length === 0) { + console.log('No apps configured in portless.json'); + process.exit(0); +} + +console.log('portless URLs for this worktree:\n'); +for (const name of names) { + console.log(` ${name.padEnd(8)} ${urlFor(name)}`); +} +console.log('\nStart all apps: portless'); +console.log('Start one app: cd && portless run '); +console.log('An app also gets its own URL via the PORTLESS_URL env var at runtime.'); From a7af4e50abd49c1fbfe4ad82b05a747018fe58ff Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:20:15 -0600 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20feat(api):=20trust=20portless?= =?UTF-8?q?=20.localhost=20origins=20in=20dev=20(U5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract ALLOWED_ORIGIN_PATTERNS/isAllowedOrigin into utils/cors-origins.ts (unit-testable) - add CORS pattern for https://..localhost[:port] - add dev-gated better-auth trustedOrigins: https://*.localhost and https://*.localhost:* (verified against better-auth wildcardMatch semantics) - 21 unit tests covering allowed/portless/rejected origins --- packages/api/src/auth/index.ts | 7 ++- packages/api/src/index.ts | 15 +----- .../src/utils/__tests__/cors-origins.test.ts | 51 +++++++++++++++++++ packages/api/src/utils/cors-origins.ts | 21 ++++++++ 4 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 packages/api/src/utils/__tests__/cors-origins.test.ts create mode 100644 packages/api/src/utils/cors-origins.ts diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 95751c1c18..9a224eaa5f 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -204,7 +204,12 @@ async function buildAuth(env: ValidatedEnv): Promise { trustedOrigins: [ env.BETTER_AUTH_URL, 'packrat://', - ...(env.ENVIRONMENT === 'development' ? ['http://localhost:*'] : []), + // Only trust localhost in development — never in production. The *.localhost + // patterns cover the portless dev proxy's per-worktree hosts + // (https://..localhost, with or without a :port). + ...(env.ENVIRONMENT === 'development' + ? ['http://localhost:*', 'https://*.localhost', 'https://*.localhost:*'] + : []), ], }); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bd3776a81d..4dd2e2348f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -14,6 +14,7 @@ import { AppContainer } from '@packrat/api/containers'; import { routes } from '@packrat/api/routes'; import { CatalogService } from '@packrat/api/services'; import { processQueueBatch } from '@packrat/api/services/etl/queue'; +import { isAllowedOrigin } from '@packrat/api/utils/cors-origins'; import type { Env } from '@packrat/api/utils/env-validation'; import { getEnv, setWorkerEnv } from '@packrat/api/utils/env-validation'; import { packratOpenApi } from '@packrat/api/utils/openapi'; @@ -23,20 +24,6 @@ import { Elysia } from 'elysia'; import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'; import type { CatalogETLMessage } from './services/etl/types'; -// Origins allowed to make cross-origin (credentialed) requests to the API. -const ALLOWED_ORIGIN_PATTERNS = [ - /^https:\/\/(www\.)?packrat\.world$/, - /^https:\/\/[\w-]+\.packrat\.world$/, - /^https:\/\/[\w-]+\.packratai\.com$/, - /^https?:\/\/[\w-]+\.workers\.dev$/, - /^http:\/\/localhost:\d+$/, - /^exp:\/\//, -]; - -function isAllowedOrigin(origin: string | null): origin is string { - return !!origin && ALLOWED_ORIGIN_PATTERNS.some((re) => re.test(origin)); -} - export const app = new Elysia({ adapter: CloudflareAdapter }) .use( cors({ diff --git a/packages/api/src/utils/__tests__/cors-origins.test.ts b/packages/api/src/utils/__tests__/cors-origins.test.ts new file mode 100644 index 0000000000..87f1f28df1 --- /dev/null +++ b/packages/api/src/utils/__tests__/cors-origins.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { isAllowedOrigin } from '../cors-origins'; + +describe('isAllowedOrigin', () => { + describe('production origins', () => { + it.each([ + 'https://packrat.world', + 'https://www.packrat.world', + 'https://app.packrat.world', + 'https://admin.packratai.com', + 'https://packrat-api.workers.dev', + 'http://localhost:3001', + 'http://localhost:8787', + 'exp://192.168.1.10:8081', + ])('allows %s', (origin) => { + expect(isAllowedOrigin(origin)).toBe(true); + }); + }); + + describe('portless local-dev proxy origins', () => { + it.each([ + // Worktree-prefixed hosts, clean :443 form (no port in URL). + 'https://portless-packrat.web.localhost', + 'https://feat-auth.api.localhost', + // Worktree-prefixed hosts, :1355 fallback form (port in URL). + 'https://portless-packrat.web.localhost:1355', + 'https://feat-auth.api.localhost:1355', + // Single-label localhost host. + 'https://web.localhost', + ])('allows %s', (origin) => { + expect(isAllowedOrigin(origin)).toBe(true); + }); + }); + + describe('rejected origins', () => { + it.each([ + null, + '', + 'https://evil.com', + 'https://packrat.world.evil.com', + // .localhost must be the final label — these are public-resolvable look-alikes. + 'https://evil.localhost.com', + 'https://x.localhostevil.com', + // http (not https) .localhost is not in the allowlist (only the proxy's https form). + 'http://portless-packrat.web.localhost:1355', + 'localhost', + ])('rejects %s', (origin) => { + expect(isAllowedOrigin(origin)).toBe(false); + }); + }); +}); diff --git a/packages/api/src/utils/cors-origins.ts b/packages/api/src/utils/cors-origins.ts new file mode 100644 index 0000000000..334a499fe2 --- /dev/null +++ b/packages/api/src/utils/cors-origins.ts @@ -0,0 +1,21 @@ +/** + * Origins allowed to make cross-origin (credentialed) requests to the API. + * + * Kept in a standalone module so the matching logic is unit-testable without + * constructing the full Elysia worker app. + */ + +export const ALLOWED_ORIGIN_PATTERNS = [ + /^https:\/\/(www\.)?packrat\.world$/, + /^https:\/\/[\w-]+\.packrat\.world$/, + /^https:\/\/[\w-]+\.packratai\.com$/, + /^https?:\/\/[\w-]+\.workers\.dev$/, + /^http:\/\/localhost:\d+$/, + // portless local-dev proxy: https://..localhost[:port] + /^https:\/\/[\w.-]+\.localhost(:\d+)?$/, + /^exp:\/\//, +]; + +export function isAllowedOrigin(origin: string | null): origin is string { + return !!origin && ALLOWED_ORIGIN_PATTERNS.some((re) => re.test(origin)); +} From d523015416f76607ea6e777a7aeb62d1f8e58923 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 13:24:53 -0600 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8=20feat(web):=20derive=20API=20ori?= =?UTF-8?q?gin=20from=20page=20origin=20under=20portless=20(U6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - centralize the 4 duplicated 'NEXT_PUBLIC_API_URL ?? localhost:8787' into getApiBaseUrl() - under portless, derive the API's sibling origin (web.localhost -> api.localhost), carrying the per-worktree prefix and port automatically (works for :443 and :1355) - explicit NEXT_PUBLIC_API_URL still wins; localhost:8787 fallback for direct runs --- apps/web/app/auth/page.tsx | 4 ++-- apps/web/components/screens/ai-screen.tsx | 4 ++-- apps/web/lib/api.ts | 4 ++-- apps/web/lib/auth-client.ts | 4 ++-- apps/web/lib/getApiBaseUrl.ts | 29 +++++++++++++++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/getApiBaseUrl.ts diff --git a/apps/web/app/auth/page.tsx b/apps/web/app/auth/page.tsx index 6c989613c1..9b55d01a14 100644 --- a/apps/web/app/auth/page.tsx +++ b/apps/web/app/auth/page.tsx @@ -1,12 +1,12 @@ 'use client'; -import { webEnv } from '@packrat/env/web'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import type React from 'react'; import { useState } from 'react'; import { setTokens } from 'web-app/lib/auth'; +import { getApiBaseUrl } from 'web-app/lib/getApiBaseUrl'; -const API_BASE = webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'; +const API_BASE = getApiBaseUrl(); function useLoginMutation() { return useMutation({ diff --git a/apps/web/components/screens/ai-screen.tsx b/apps/web/components/screens/ai-screen.tsx index df56080a53..82c90c07cd 100644 --- a/apps/web/components/screens/ai-screen.tsx +++ b/apps/web/components/screens/ai-screen.tsx @@ -1,10 +1,10 @@ 'use client'; import { type UIMessage, useChat } from '@ai-sdk/react'; -import { webEnv } from '@packrat/env/web'; import { DefaultChatTransport, type TextUIPart } from 'ai'; import Cookies from 'js-cookie'; import { Bot, Send, User } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { getApiBaseUrl } from 'web-app/lib/getApiBaseUrl'; import { cn } from 'web-app/lib/utils'; import { useWeight } from 'web-app/lib/weight-context'; @@ -15,7 +15,7 @@ const STARTER_PROMPTS = [ 'Best ultralight tarp vs tent options?', ]; -const API_BASE = webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'; +const API_BASE = getApiBaseUrl(); function getTextContent(msg: UIMessage): string { return msg.parts diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 437bfa6799..713c7f0a99 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,9 +1,9 @@ import { createApiClient } from '@packrat/api-client'; -import { webEnv } from '@packrat/env/web'; import { authClient } from 'web-app/lib/auth-client'; +import { getApiBaseUrl } from 'web-app/lib/getApiBaseUrl'; export const apiClient = createApiClient({ - baseUrl: webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787', + baseUrl: getApiBaseUrl(), auth: { getAccessToken: async () => { const { data } = await authClient.getSession(); diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts index 0b46ceecf2..dec7a9b8ec 100644 --- a/apps/web/lib/auth-client.ts +++ b/apps/web/lib/auth-client.ts @@ -1,10 +1,10 @@ 'use client'; -import { webEnv } from '@packrat/env/web'; import { nextCookies } from 'better-auth/next-js'; import { createAuthClient } from 'better-auth/react'; +import { getApiBaseUrl } from 'web-app/lib/getApiBaseUrl'; export const authClient = createAuthClient({ - baseURL: webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787', + baseURL: getApiBaseUrl(), plugins: [nextCookies()], }); diff --git a/apps/web/lib/getApiBaseUrl.ts b/apps/web/lib/getApiBaseUrl.ts new file mode 100644 index 0000000000..3a2788ff00 --- /dev/null +++ b/apps/web/lib/getApiBaseUrl.ts @@ -0,0 +1,29 @@ +import { webEnv } from '@packrat/env/web'; + +const WEB_HOST_SUFFIX = 'web.localhost'; + +/** + * Resolves the PackRat API base URL for the web app. + * + * Priority: + * 1. Explicit `NEXT_PUBLIC_API_URL` — production, CI, or a manually-set portless URL. + * 2. Under the portless dev proxy: derive the API's sibling origin from the page's own + * origin. The web app is served at `[.]web.localhost[:port]` and the API at + * `[.]api.localhost[:port]`, so swapping the `web` label to `api` keeps the + * per-worktree prefix and port automatically — no per-worktree env wiring needed. + * 3. Fall back to the conventional local API port for direct (non-portless) runs. + */ +export function getApiBaseUrl(): string { + if (webEnv.NEXT_PUBLIC_API_URL) return webEnv.NEXT_PUBLIC_API_URL; + + if (typeof window !== 'undefined') { + const { origin, hostname } = window.location; + // host is `[.]web.localhost`; swap the `web` app-label to `api`. + if (hostname === WEB_HOST_SUFFIX || hostname.endsWith(`.${WEB_HOST_SUFFIX}`)) { + const apiHostname = `${hostname.slice(0, -WEB_HOST_SUFFIX.length)}api.localhost`; + return origin.replace(hostname, apiHostname); + } + } + + return 'http://localhost:8787'; +} From a795e21ca141df18fd81dbac92b5ecbd0d9eb155 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 14:26:33 -0600 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=9D=20docs(portless):=20dev=20work?= =?UTF-8?q?flow=20+=20recovery=20guide,=20add=20portless:clean=20(U10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/portless-dev-workflow.md: host setup, :443-vs-:1355, root-proxy pitfall, multi-agent workflow, service discovery, Expo (planned), recovery - portless:clean script (portless prune) for stale-process recovery --- docs/portless-dev-workflow.md | 95 +++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 96 insertions(+) create mode 100644 docs/portless-dev-workflow.md diff --git a/docs/portless-dev-workflow.md b/docs/portless-dev-workflow.md new file mode 100644 index 0000000000..182e6df664 --- /dev/null +++ b/docs/portless-dev-workflow.md @@ -0,0 +1,95 @@ +# Portless Local Dev Workflow + +[portless](https://github.com/vercel-labs/portless) gives every local dev server a +stable, per-worktree `.localhost` URL instead of a hardcoded port — so 50+ parallel +agents (and humans) stop colliding on `:3001`/`:8787`/`:8081`. + +## One-time host setup + +```bash +bun run portless:setup # trusts the local CA (one sudo for the trust store) +``` + +Then start the proxy **as your user** (see the port note below). The host setup is +once per machine; re-run `portless:setup` after a node version bump (mise moves the +node binary). + +> **Never run the proxy under sudo.** A root-owned proxy uses root's `~/.portless` +> state, which is disjoint from your user's — it answers requests but **404s every +> backend** your dev servers register. If a root proxy is squatting `:443`: +> `sudo portless proxy stop` once, then start it as your user. + +### Clean `:443` URLs vs no-sudo `:1355` + +portless 0.13.1 hardcodes a sudo elevation for `:443` (it ignores the +`cap_net_bind_service` capability). Pick one: + +- **No-sudo (default):** let portless fall back to a user-owned proxy on `:1355`. + Fully solves port collisions; URLs carry `:1355`. Just run `portless`. +- **Clean `:443`:** needs sudo at proxy start, kept user-context via one of: + `sudo -E portless proxy start` (preserves `HOME` so it shares your `~/.portless`), + a passwordless-sudo rule scoped to `portless proxy start`, or `portless service + install` (verify it runs as your user). + +## Normal multi-agent workflow + +```bash +portless # from repo root: starts all workspace apps via turbo, + # each at https://[.].localhost[:port] +bun run portless:urls # print this worktree's service URLs +``` + +- **Naming:** apps resolve as `web`, `admin`, `guides`, `landing`, `trails`, `api`, + `mcp` (see `portless.json`). In a linked worktree the branch is prepended: + `feat-x.web.localhost`. +- **Discovery:** each dev process is handed its own `PORTLESS_URL`. Use + `bun run portless:urls` or `portless get ` to look up a sibling service. +- **Always use the worktree-aware form** (`portless` / `portless run`), never the + explicit `portless ` form — the latter skips worktree prefixing and + the proxy 404s it. + +### How services find each other + +- **Web → API:** `apps/web` derives the API origin from its own page origin + (`web.localhost` → `api.localhost`), carrying the worktree prefix and port + automatically. No per-worktree env wiring. An explicit `NEXT_PUBLIC_API_URL` still + wins. See `apps/web/lib/getApiBaseUrl.ts`. +- **Auth/CORS:** the API trusts `https://*.localhost[:*]` origins in development + (`packages/api/src/utils/cors-origins.ts` and better-auth `trustedOrigins`), so + login works through the proxy. These relaxations are dev-gated — never production. +- **`$PORT`:** dev scripts honor portless's injected `PORT` + (`next dev --port ${PORT:-…}`, `wrangler dev --port ${PORT:-8787}`); running a + script directly (no portless) falls back to its conventional port. + +## Expo (simulator + physical device) + +> Status: planned (U7–U9). Requires a real simulator/emulator/device to validate. + +- **Simulator/emulator:** point `EXPO_PUBLIC_API_URL` at the named local API; Android + emulator keeps the `10.0.2.2` mapping (`apps/expo/lib/api/getBaseUrl.ts`). +- **Physical device (LAN):** enable portless LAN mode (`PORTLESS_LAN=1`) so services + bind LAN-reachably; the device must trust the portless CA and be on the same subnet. + `.localhost` is loopback-only and is **not** used by devices — they reach the API by + its LAN-reachable name/IP. + +## Recovery (stale processes / wedged proxy) + +```bash +bun run portless:clean # portless prune — kill orphaned dev servers from crashes +portless list # show active routes +portless clean # full reset: remove portless state, CA trust, hosts entries +``` + +If a dev server is wedged on a port but `portless prune` reports 0 killed (a +reparented worker the proxy no longer tracks), kill it directly: + +```bash +fuser -k 4567/tcp # or: lsof -ti:4567 | xargs kill -9 +``` + +## Out of scope / leave alone + +- The Neon HTTP proxy (`db.localtest.me`, `packages/api/src/index.ts`) is a separate, + already-working concern — portless does not touch it. +- Don't dockerize the web/Worker dev servers: containers can't resolve the host's + `.localhost` names. diff --git a/package.json b/package.json index c66878e90e..fcc907603d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "mcp": "bun run --cwd packages/mcp dev", "mcp:deploy": "bun run --cwd packages/mcp deploy", "portless:check": "bun run scripts/portless-check.ts", + "portless:clean": "portless prune", "portless:setup": "bash scripts/portless-setup.sh", "portless:urls": "bun run scripts/portless-urls.ts", "test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts", From 89eeaa5ed08ae62c17b7193d266855cbc0210e85 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 14:36:06 -0600 Subject: [PATCH 8/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(web):=20gener?= =?UTF-8?q?alize=20portless=20API-origin=20derivation=20(code-review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swap the app label generically (.localhost -> api.localhost) instead of hardcoding 'web.localhost' — same logic now works for admin/trails when adopted - document deferred portless API-origin parity for admin/trails/guides (U6 review finding) --- apps/web/lib/getApiBaseUrl.ts | 16 ++++++++-------- ...-feat-portless-turborepo-dev-workflow-plan.md | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/getApiBaseUrl.ts b/apps/web/lib/getApiBaseUrl.ts index 3a2788ff00..1580a8e6c3 100644 --- a/apps/web/lib/getApiBaseUrl.ts +++ b/apps/web/lib/getApiBaseUrl.ts @@ -1,15 +1,13 @@ import { webEnv } from '@packrat/env/web'; -const WEB_HOST_SUFFIX = 'web.localhost'; - /** * Resolves the PackRat API base URL for the web app. * * Priority: * 1. Explicit `NEXT_PUBLIC_API_URL` — production, CI, or a manually-set portless URL. * 2. Under the portless dev proxy: derive the API's sibling origin from the page's own - * origin. The web app is served at `[.]web.localhost[:port]` and the API at - * `[.]api.localhost[:port]`, so swapping the `web` label to `api` keeps the + * origin. Pages are served at `[.].localhost[:port]` and the API at + * `[.]api.localhost[:port]`, so swapping the app label for `api` keeps the * per-worktree prefix and port automatically — no per-worktree env wiring needed. * 3. Fall back to the conventional local API port for direct (non-portless) runs. */ @@ -18,10 +16,12 @@ export function getApiBaseUrl(): string { if (typeof window !== 'undefined') { const { origin, hostname } = window.location; - // host is `[.]web.localhost`; swap the `web` app-label to `api`. - if (hostname === WEB_HOST_SUFFIX || hostname.endsWith(`.${WEB_HOST_SUFFIX}`)) { - const apiHostname = `${hostname.slice(0, -WEB_HOST_SUFFIX.length)}api.localhost`; - return origin.replace(hostname, apiHostname); + if (hostname.endsWith('.localhost')) { + // host is `[.].localhost`; replace the app label with `api`. + const labels = hostname.split('.'); + labels[labels.length - 2] = 'api'; + const apiHostname = labels.join('.'); + if (apiHostname !== hostname) return origin.replace(hostname, apiHostname); } } diff --git a/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md b/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md index 4426aa6844..c480723707 100644 --- a/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md +++ b/docs/plans/2026-06-01-001-feat-portless-turborepo-dev-workflow-plan.md @@ -239,6 +239,7 @@ The tree is a scope declaration, not a constraint — per-unit `**Files:**` are - Remote Turbo cache configuration (orthogonal to portless). - Migrating Turbo itself onto `development` (separate effort; this plan rides the `feat/turbo-l4` base). +- **Portless API-origin parity for `apps/admin`, `apps/trails`, `apps/guides`** (surfaced in U6 code review). U6 wired `apps/web` only. These need their own care, not a regression from U6: `apps/admin` requires `NEXT_PUBLIC_API_URL` (`z.string().url()`, no default) so it can't self-derive without an env-schema change; `apps/trails` defaults to **prod** (`https://api.packratai.com`) so changing its resolution risks prod behavior. The right shape is a shared `resolveApiBaseUrl(explicit, fallback)` (the U6 derivation is generic — `.localhost` → `api.localhost`) extracted to `@packrat/api-client` and adopted per-app with verification. `apps/landing` needs nothing (no API calls); `apps/guides` API refs are build-time CLI scripts, not a portless runtime client. ### Out of scope (origin) From f63d45f0a6757ff14175aa46992e45818a69c7fe Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Mon, 1 Jun 2026 16:46:21 -0600 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=94=92=20chore(deps):=20add=20portles?= =?UTF-8?q?s=20to=20bun.lock=20(fix=20CI=20frozen-lockfile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index e13363a3da..1f90efe6af 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "lefthook": "^1.11.14", + "portless": "^0.13.1", "semver": "catalog:", "sort-package-json": "^3.6.1", "turbo": "^2.5.0", @@ -4102,6 +4103,8 @@ "pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + "portless": ["portless@0.13.1", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-1B+8Xd4tB1gOIkRHOUEi/LCOp8PIxUsL3ACBX5G2UkKw25TSRNUqAlQcBGxweJgdXb9DPQFX5utKLRUzPFhMnA=="], + "portscanner": ["portscanner@2.2.0", "", { "dependencies": { "async": "^2.6.0", "is-number-like": "^1.0.3" } }, "sha512-IFroCz/59Lqa2uBvzK3bKDbDDIEaAY8XJ1jFxcLWTqosrsc32//P4VuSB2vZXoHiHqOmx8B5L5hnKOxL/7FlPw=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],