diff --git a/README.md b/README.md index 0f75ffd..61f1014 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The framework generators take a few minutes. You get a directory with one commit | Adapter | Flag | Tier | |---|---|---| | `nextjs` | `--web` | A | +| `vite-react` | `--web` | A | | `nestjs` | `--api` | A | | `laravel-api` | `--api` | A | | `flask` | `--api` | A | diff --git a/adapters/vite-react/.dockerignore b/adapters/vite-react/.dockerignore new file mode 100644 index 0000000..d982745 --- /dev/null +++ b/adapters/vite-react/.dockerignore @@ -0,0 +1,9 @@ +# copied from a pinned build stage; a host copy would overwrite it +node_modules +dist +coverage + +.env +.env.* +!.env.example +.git diff --git a/adapters/vite-react/.env.example b/adapters/vite-react/.env.example new file mode 100644 index 0000000..85c8244 --- /dev/null +++ b/adapters/vite-react/.env.example @@ -0,0 +1,3 @@ +# Deployed, API_URL comes from the project root's .env (example.env). Only +# VITE_* variables reach client code, and they are baked in at build time: +# anything that differs per environment belongs in /config.json (ADR-0014). diff --git a/adapters/vite-react/.prettierignore b/adapters/vite-react/.prettierignore new file mode 100644 index 0000000..0730306 --- /dev/null +++ b/adapters/vite-react/.prettierignore @@ -0,0 +1,2 @@ +pnpm-lock.yaml +dist diff --git a/adapters/vite-react/Caddyfile b/adapters/vite-react/Caddyfile new file mode 100644 index 0000000..102cd86 --- /dev/null +++ b/adapters/vite-react/Caddyfile @@ -0,0 +1,30 @@ +{ + admin off + auto_https off + persist_config off +} + +:8080 { + handle /health/live { + respond "ok" 200 + } + + # {$API_URL} is read when Caddy loads this file at container start, so one + # image serves every environment (ADR-0014). Unset means same origin. + handle /config.json { + header Content-Type application/json + header Cache-Control no-store + respond `{"apiUrl":"{$API_URL}"}` 200 + } + + handle { + # Hashed assets may cache; anything else must revalidate, or a browser + # holding an old index.html asks for assets a deploy removed and the + # fallback below answers them with HTML. + @revalidate not path /assets/* + header @revalidate Cache-Control no-cache + root * /srv + try_files {path} /index.html + file_server + } +} diff --git a/adapters/vite-react/Dockerfile b/adapters/vite-react/Dockerfile new file mode 100644 index 0000000..e6619a4 --- /dev/null +++ b/adapters/vite-react/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS build +WORKDIR /app +RUN corepack enable +# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm +# refuses esbuild's postinstall build non-interactively. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +# @SERVICE_SETUP@ +RUN pnpm exec vite build + +FROM caddy:2.11.7-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f AS runtime +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv +# The image's /config and /data are root-owned; caddy writes state there even +# with persist_config off. +ENV XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy +USER nobody +EXPOSE 8080 +# 127.0.0.1, not localhost: busybox wget takes the ::1 answer first and does +# not fall back (see adapters/nextjs/Dockerfile). +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1 diff --git a/adapters/vite-react/Dockerfile.workspace b/adapters/vite-react/Dockerfile.workspace new file mode 100644 index 0000000..220eb80 --- /dev/null +++ b/adapters/vite-react/Dockerfile.workspace @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +# Used instead of Dockerfile when every app is typescript: apps/web is then a +# pnpm workspace member with no manifests of its own. +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS build +WORKDIR /app +RUN corepack enable +# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm +# refuses esbuild's postinstall build non-interactively. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/@APP_FILTER@/package.json apps/@APP_FILTER@/package.json +RUN pnpm --filter @APP_FILTER@ install --frozen-lockfile +COPY . . +WORKDIR /app/apps/@APP_FILTER@ +# @SERVICE_SETUP@ +RUN pnpm exec vite build + +FROM caddy:2.11.7-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f AS runtime +COPY apps/@APP_FILTER@/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/apps/@APP_FILTER@/dist /srv +# The image's /config and /data are root-owned; caddy writes state there even +# with persist_config off. +ENV XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy +USER nobody +EXPOSE 8080 +# 127.0.0.1, not localhost: busybox wget takes the ::1 answer first and does +# not fall back (see adapters/nextjs/Dockerfile). +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1 diff --git a/adapters/vite-react/adapter.env b/adapters/vite-react/adapter.env new file mode 100644 index 0000000..518078d --- /dev/null +++ b/adapters/vite-react/adapter.env @@ -0,0 +1,13 @@ +ADAPTER_NAME="vite-react" +ADAPTER_ROLE="web" +ADAPTER_TIER="A" +ADAPTER_LANGUAGE="typescript" +ADAPTER_FAMILY="vite" +# Pinned to the major. --eslint: v9 defaults React templates to oxlint +# (docs/decisions/0008). { + vi.unstubAllGlobals(); +}); + +test("loadConfig returns the served config", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ apiUrl: "https://api.example" })), + ); + await expect(loadConfig()).resolves.toEqual({ + apiUrl: "https://api.example", + }); +}); + +test("loadConfig rejects when the server has no config", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })), + ); + await expect(loadConfig()).rejects.toThrow("/config.json answered 404"); +}); diff --git a/adapters/vite-react/src/config.ts b/adapters/vite-react/src/config.ts new file mode 100644 index 0000000..d638b96 --- /dev/null +++ b/adapters/vite-react/src/config.ts @@ -0,0 +1,14 @@ +export interface RuntimeConfig { + apiUrl: string; +} + +// Fetched at run time, not read from import.meta.env: a VITE_* value is baked +// into the bundle at build time, and one image must serve every environment +// (ADR-0014). +export async function loadConfig(): Promise { + const response = await fetch("/config.json"); + if (!response.ok) { + throw new Error(`/config.json answered ${response.status}`); + } + return (await response.json()) as RuntimeConfig; +} diff --git a/docs/decisions/0025-vite-react-is-served-by-caddy-with-runtime-config.md b/docs/decisions/0025-vite-react-is-served-by-caddy-with-runtime-config.md new file mode 100644 index 0000000..4639a0e --- /dev/null +++ b/docs/decisions/0025-vite-react-is-served-by-caddy-with-runtime-config.md @@ -0,0 +1,40 @@ +# 0025 — vite-react is served by Caddy, with runtime config at /config.json + +Status: Accepted +Date: 2026-10-14 + +## Context + +A Vite single-page app builds to static files. At run time it needs only a +server that answers a health probe, serves `dist/`, and falls back to +`index.html` for client-side routes. Vite inlines `VITE_*` variables into the +bundle at build time, which breaks ADR-0014's second seam: one image, +configured by its environment. + +## Decision + +The runtime stage is `caddy:2.11.7-alpine`, pinned by digest, running as +`nobody`. Its `Caddyfile` answers `/health/live` itself, so the probe never +depends on `index.html`; renders `/config.json` from the container's `API_URL` +when Caddy starts; and serves every other path with +`try_files {path} /index.html`. Client code reads per-environment values +through `src/config.ts`'s `loadConfig()`, not `import.meta.env`. + +## Consequences + +- The runtime image has no Node. +- A new per-environment value means a new key in the `Caddyfile`'s + `/config.json` response and in `RuntimeConfig`. +- `VITE_*` stays usable for values that never differ between environments. +- `vite dev` serves `public/config.json`; the image's route shadows it. +- A missing asset path also answers `index.html` with 200, the usual SPA + fallback trade-off. + +## Alternatives considered + +- **nginx-unprivileged.** Works, but SPA fallback, a health route and env + rendering need `envsubst` templates and a longer config. +- **Build-time `VITE_*` only.** One image per environment, contradicting + ADR-0014. +- **Same-origin `/api` behind a reverse proxy.** Needs the proxy ADR-0022 + declines to generate. diff --git a/docs/runbook/add-an-adapter.md b/docs/runbook/add-an-adapter.md index f830b1e..0d661d1 100644 --- a/docs/runbook/add-an-adapter.md +++ b/docs/runbook/add-an-adapter.md @@ -34,7 +34,7 @@ When: a framework needs to be generated that `scaffold list --adapters` does not - Pin the language in a local `[tools]` block, never at the project root. Where the language's own tool owns the pin, pin that tool: `adapters/flask` pins `uv`, and `adapters/flask/.python-version` pins python. - `format`, `lint` and `check` must not write; `scaffold lint` rejects `--write`, `--fix` and similar flags in them. -4. Write the overlay files. Every file in the directory is copied into the app except `adapter.env` and `lefthook.fragment.yml` (ADR-0003). +4. Write the overlay files. Every file in the directory is copied into the app except `adapter.env`, `lefthook.fragment.yml` and `example.env.fragment` (ADR-0003). | File | Rule | | --- | --- | @@ -43,6 +43,7 @@ When: a framework needs to be generated that `scaffold list --adapters` does not | `.dockerignore` | Required by `tests/compose.bats` | | `.env.example` | The app's own variables | | `lefthook.fragment.yml` | Hooks merged into the project's `lefthook.yml`; `{}` for none | +| `example.env.fragment` | Optional. Appended to the project's `example.env`, the root `.env` compose hands the container; `adapters/vite-react` declares `API_URL` here | 5. For an `api` or `app` role, add `services//drivers/.sh` to every service, unless the family already has drivers. diff --git a/docs/superpowers/plans/2026-10-14-vite-react-adapter.md b/docs/superpowers/plans/2026-10-14-vite-react-adapter.md new file mode 100644 index 0000000..1ca79b7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-vite-react-adapter.md @@ -0,0 +1,528 @@ +# `vite-react` Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `vite-react` web adapter: a React single-page app built by Vite and served as static files by Caddy, with runtime config at `/config.json`. + +**Architecture:** Like every adapter, it runs the framework's own generator (`create-vite`) and overlays files on its output (ADR-0003). The Docker image builds with Node and serves `dist/` from a Caddy runtime stage. Caddy answers the health probe itself, falls back to `index.html` for client routes, and renders `/config.json` from the container's environment at start. + +**Tech Stack:** bash 4 + bats, mise, pnpm, create-vite 9, React 19, Vite 8, TypeScript 6, ESLint, prettier, vitest, Caddy 2.11.7, Node 24.15.0. + +**Spec:** `docs/superpowers/specs/2026-10-14-vite-react-adapter-design.md` + +## Global Constraints + +- Base images pinned by digest. Node: `node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f`. Caddy: `caddy:2.11.7-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f`. +- Generator: `pnpm create vite@9 "$APP_DIR" --template react-ts --eslint --no-immediate --no-interactive `, where `RuntimeConfig` is `{ apiUrl: string }` (`src/config.ts`). The runtime image serves `GET /health/live` and `GET /config.json`. + +- [ ] **Step 1: Write the failing tests.** `tests/new-vite-react.bats`: + +```bash +setup() { + load 'helpers/setup' + WORKDIR="$(mktemp -d)" + PROJECT="${WORKDIR}/demo" +} + +teardown() { + remove_workdir "$WORKDIR" +} + +@test "vite-react generates an app at apps/web" { + run scaffold new "$PROJECT" --web vite-react + assert_ok + [ -f "${PROJECT}/apps/web/index.html" ] + [ -f "${PROJECT}/apps/web/Caddyfile" ] + [ -f "${PROJECT}/apps/web/mise.toml" ] +} + +@test "vite-react adds no tool to the project root" { + scaffold new "$PROJECT" --web vite-react + [ "$(yq -p toml -oy '.tools' "${PROJECT}/mise.toml")" = \ + "$(yq -p toml -oy '.tools' "${SCAFFOLD_ROOT}/common/mise.root.toml")" ] +} + +@test "vite-react lints with eslint, not oxlint" { + scaffold new "$PROJECT" --web vite-react + [ -f "${PROJECT}/apps/web/eslint.config.js" ] + run jq -r '.devDependencies.oxlint // "absent"' "${PROJECT}/apps/web/package.json" + [ "$output" = "absent" ] +} + +@test "the generated web app passes its own checklist" { + scaffold new "$PROJECT" --web vite-react + cd "$PROJECT" + run mise run //apps/web:checklist + assert_ok +} + +@test "the caddy runtime answers health and config without index.html" { + scaffold new "$PROJECT" --web vite-react + run grep -c 'handle /health/live\|handle /config.json' "${PROJECT}/apps/web/Caddyfile" + [ "$output" = "2" ] + run grep -c '^FROM node' "${PROJECT}/apps/web/Dockerfile" + [ "$output" = "1" ] +} + +@test "vite-react and nestjs share one workspace" { + scaffold new "$PROJECT" --web vite-react --api nestjs --db none + [ -f "${PROJECT}/packages/types/package.json" ] + run find "$PROJECT" -name pnpm-lock.yaml -not -path '*/node_modules/*' + [ "$output" = "${PROJECT}/pnpm-lock.yaml" ] + run grep -c -- '--filter web' "${PROJECT}/apps/web/Dockerfile" + [ "$output" = "1" ] +} + +@test "a role mismatch is rejected" { + run scaffold new "$PROJECT" --api vite-react + [ "$status" -eq 1 ] + [[ "$output" == *"adapter vite-react has role web"* ]] +} +``` + +- [ ] **Step 2: Run them and confirm they fail.** + +Run: `bats tests/new-vite-react.bats` +Expected: FAIL with `unknown adapter: vite-react`. + +- [ ] **Step 3: Write `adapters/vite-react/adapter.env`.** + +```bash +ADAPTER_NAME="vite-react" +ADAPTER_ROLE="web" +ADAPTER_TIER="A" +ADAPTER_LANGUAGE="typescript" +ADAPTER_FAMILY="vite" +# Pinned to the major. --eslint: v9 defaults React templates to oxlint +# (docs/decisions/0008). { + const response = await fetch("/config.json"); + if (!response.ok) { + throw new Error(`/config.json answered ${response.status}`); + } + return (await response.json()) as RuntimeConfig; +} +``` + +`adapters/vite-react/src/config.test.ts` (no `.js` suffix: the template uses `moduleResolution: bundler`): + +```ts +import { afterEach, expect, test, vi } from "vitest"; + +import { loadConfig } from "./config"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("loadConfig returns the served config", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ apiUrl: "https://api.example" })), + ); + await expect(loadConfig()).resolves.toEqual({ apiUrl: "https://api.example" }); +}); + +test("loadConfig rejects when the server has no config", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 404 }))); + await expect(loadConfig()).rejects.toThrow("/config.json answered 404"); +}); +``` + +`adapters/vite-react/public/config.json` (for `vite dev`; Caddy's route shadows it in the image; 3001 is nestjs's dev port): + +```json +{ "apiUrl": "http://localhost:3001" } +``` + +- [ ] **Step 5: Write `adapters/vite-react/Caddyfile`.** + +``` +{ + admin off + auto_https off + persist_config off +} + +:8080 { + handle /health/live { + respond "ok" 200 + } + + # {$API_URL} is read when Caddy loads this file at container start, so one + # image serves every environment (ADR-0014). Unset means same origin. + handle /config.json { + header Content-Type application/json + header Cache-Control no-store + respond `{"apiUrl":"{$API_URL}"}` 200 + } + + handle { + root * /srv + try_files {path} /index.html + file_server + } +} +``` + +- [ ] **Step 6: Write `adapters/vite-react/Dockerfile`.** + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS build +WORKDIR /app +RUN corepack enable +# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm +# refuses esbuild's postinstall build non-interactively. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +# @SERVICE_SETUP@ +RUN pnpm exec vite build + +FROM caddy:2.11.7-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f AS runtime +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv +# The image's /config and /data are root-owned; caddy writes state there even +# with persist_config off. +ENV XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy +USER nobody +EXPOSE 8080 +# 127.0.0.1, not localhost: busybox wget takes the ::1 answer first and does +# not fall back (see adapters/nextjs/Dockerfile). +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1 +``` + +- [ ] **Step 7: Write `adapters/vite-react/Dockerfile.workspace`.** + +```dockerfile +# syntax=docker/dockerfile:1 + +# Used instead of Dockerfile when every app is typescript: apps/web is then a +# pnpm workspace member with no manifests of its own. +FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS build +WORKDIR /app +RUN corepack enable +# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm +# refuses esbuild's postinstall build non-interactively. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/@APP_FILTER@/package.json apps/@APP_FILTER@/package.json +RUN pnpm --filter @APP_FILTER@ install --frozen-lockfile +COPY . . +WORKDIR /app/apps/@APP_FILTER@ +# @SERVICE_SETUP@ +RUN pnpm exec vite build + +FROM caddy:2.11.7-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f AS runtime +COPY apps/@APP_FILTER@/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/apps/@APP_FILTER@/dist /srv +# The image's /config and /data are root-owned; caddy writes state there even +# with persist_config off. +ENV XDG_CONFIG_HOME=/tmp/caddy XDG_DATA_HOME=/tmp/caddy +USER nobody +EXPOSE 8080 +# 127.0.0.1, not localhost: busybox wget takes the ::1 answer first and does +# not fall back (see adapters/nextjs/Dockerfile). +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO- http://127.0.0.1:8080/health/live || exit 1 +``` + +- [ ] **Step 8: Write the remaining files.** + +`adapters/vite-react/mise.toml`: + +```toml +[tasks.install] +# The first frozen install after packages/types joins the workspace relinks +# node_modules, and pnpm must not stop to ask. +env = { npm_config_confirm_modules_purge = "false" } +run = "pnpm install --frozen-lockfile" + +[tasks.format] +run = "pnpm exec prettier --check ." + +[tasks."format-fix"] +run = "pnpm exec prettier --write ." + +[tasks.lint] +run = "pnpm exec eslint . --max-warnings 0" + +[tasks.check] +# tsconfig.app.json and tsconfig.node.json set noEmit; -b only writes +# tsbuildinfo under node_modules/.tmp. +run = "pnpm exec tsc -b" + +[tasks.test] +run = "pnpm exec vitest --run" + +[tasks.build] +run = "pnpm exec vite build" + +[tasks.ci-unit] +# same reason as :install's env above — ci-unit calls :install too. +env = { npm_config_confirm_modules_purge = "false" } +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":lint" }, + { task = ":check" }, + { task = ":test" }, +] + +[tasks.checklist] +run = [{ task = ":ci-unit" }, { task = ":build" }] +``` + +`adapters/vite-react/.env.example`: + +``` +# Read by Caddy when the container starts and served at /config.json; empty +# means the api is on the same origin. +API_URL= +# Only VITE_* variables reach client code, and they are baked in at build time: +# anything that differs per environment belongs in /config.json (ADR-0014). +``` + +`adapters/vite-react/.prettierignore`: + +``` +pnpm-lock.yaml +dist +``` + +`adapters/vite-react/.dockerignore`: + +``` +# copied from a pinned build stage; a host copy would overwrite it +node_modules +dist +coverage + +.env +.env.* +!.env.example +.git +``` + +`adapters/vite-react/lefthook.fragment.yml`: + +```yaml +# prettier in the common layer already covers typescript sources. +{} +``` + +- [ ] **Step 9: Run the suite, the static Dockerfile checks and the linter.** + +Run: `bats tests/new-vite-react.bats tests/compose.bats && ./scaffold lint` +Expected: all PASS; `scaffold lint` prints nothing. + +If `checklist` fails at `format`, a file the generator wrote is unformatted. Post-generate runs `prettier --write .` after the overlay is copied (`apply_adapter` in `lib/adapter.sh`), so check that `.prettierignore` isn't hiding it. If `vitest` refuses Vite 8 as a peer, pin the vitest major that supports it in `ADAPTER_POST_GENERATE` (`pnpm add -D prettier vitest@`) and record why in its comment. + +- [ ] **Step 10: Prove the runtime image.** This check was not run during design. + +```bash +./scaffold new ../probe-web --web vite-react +cd ../probe-web/apps/web +docker build -t probe-web . +docker run -d --name probe-web -e API_URL=https://api.example -p 18080:8080 probe-web +``` + +Check each of these: +- `curl -s -o /dev/null -w '%{http_code}\n' localhost:18080/health/live` prints `200`. +- `curl -s localhost:18080/config.json` prints `{"apiUrl":"https://api.example"}`. +- `curl -s -w '%{http_code}\n' localhost:18080/some/client/route | tail -c 4` ends `200`, and the body is `index.html`. +- `docker inspect -f '{{.State.Health.Status}} {{.Config.User}}' probe-web` prints `healthy nobody` (allow 30 s). +- `docker exec probe-web sh -c 'command -v node || echo none'` prints `none`. + +Then check that a restart picks up a new `API_URL`, and that an unset one is still valid JSON: + +```bash +docker rm -f probe-web +docker run -d --name probe-web -p 18080:8080 probe-web +curl -s localhost:18080/config.json # {"apiUrl":""} +docker rm -f probe-web +cd - && rm -rf ../probe-web && docker image rm probe-web +``` + +If Caddy exits as `nobody`, read `docker logs probe-web`. A permission error on `/config` or `/data` means the `XDG_*` values didn't apply; fix both Dockerfiles and re-run this step. + +- [ ] **Step 11: Commit.** + +```bash +git add adapters/vite-react tests/new-vite-react.bats +git commit -m "feat(adapters): add the vite-react web adapter served by caddy" +``` + +--- + +### Task 2: ADR 0025 and the adapter tables + +**Files:** +- Create: `docs/decisions/0025-vite-react-is-served-by-caddy-with-runtime-config.md` +- Modify: `README.md` (adapter table near line 75), `docs/tour/08-adapters.md` (any adapter table) + +- [ ] **Step 1: Write the ADR.** + +```markdown +# 0025 — vite-react is served by Caddy, with runtime config at /config.json + +Status: Accepted +Date: 2026-10-14 + +## Context + +A Vite single-page app builds to static files. At run time it needs only a +server that answers a health probe, serves `dist/`, and falls back to +`index.html` for client-side routes. Vite inlines `VITE_*` variables into the +bundle at build time, which breaks ADR-0014's second seam: one image, +configured by its environment. + +## Decision + +The runtime stage is `caddy:2.11.7-alpine`, pinned by digest, running as +`nobody`. Its `Caddyfile` answers `/health/live` itself, so the probe never +depends on `index.html`; renders `/config.json` from the container's `API_URL` +when Caddy starts; and serves every other path with +`try_files {path} /index.html`. Client code reads per-environment values +through `src/config.ts`'s `loadConfig()`, not `import.meta.env`. + +## Consequences + +- The runtime image has no Node. +- A new per-environment value means a new key in the `Caddyfile`'s + `/config.json` response and in `RuntimeConfig`. +- `VITE_*` stays usable for values that never differ between environments. +- `vite dev` serves `public/config.json`; the image's route shadows it. +- A missing asset path also answers `index.html` with 200, the usual SPA + fallback trade-off. + +## Alternatives considered + +- **nginx-unprivileged.** Works, but SPA fallback, a health route and env + rendering need `envsubst` templates and a longer config. +- **Build-time `VITE_*` only.** One image per environment, contradicting + ADR-0014. +- **Same-origin `/api` behind a reverse proxy.** Needs the proxy ADR-0022 + declines to generate. +``` + +- [ ] **Step 2: Check it.** + +Run: `adr="$(mktemp -d)" && mkdir "$adr/scripts" && cp common/docs/scripts/check-adrs.mjs "$adr/scripts/" && ln -s "$PWD/docs/decisions" "$adr/decisions" && node "$adr/scripts/check-adrs.mjs"` +Expected: exit 0, no output. The script resolves `../decisions` from its own location, hence the symlink. + +- [ ] **Step 3: Add the adapter rows.** In `README.md`'s adapter table, after the `nextjs` row: + +```markdown +| `vite-react` | web | A | +``` + +Add the same row to any adapter table in `docs/tour/08-adapters.md`. + +- [ ] **Step 4: Run the docs suites.** + +Run: `bats tests/documentation.bats tests/docs.bats` +Expected: all PASS, including "every adr referenced by the tour exists". + +- [ ] **Step 5: Commit.** + +```bash +git add docs/decisions/0025-vite-react-is-served-by-caddy-with-runtime-config.md README.md docs/tour/08-adapters.md +git commit -m "docs: record serving vite-react with caddy" +``` + +--- + +### Task 3: Acceptance run + +No new code. Each step maps to a success criterion in the spec. Record the outputs in the PR description. A failure goes back to Task 1 or 2 and is fixed there, test first. + +- [ ] **Step 1 (criteria 1, 2):** `./scaffold lint` prints nothing and exits 0. `./scaffold list --adapters` shows `vite-react`, `web`, `A`. + +- [ ] **Step 2 (criterion 3):** The runbook "Verify" block, with the second-TypeScript-app checks: + +```bash +./scaffold new ../probe --web vite-react +(cd ../probe && mise run //apps/web:checklist) +./scaffold new ../probe2 --web vite-react --api nestjs --db none +./scaffold new ../probe3 --api nestjs +toolbox="$PWD" +(cd ../probe3 && "$toolbox/scaffold" add apps/web --adapter vite-react) +find ../probe3 -name pnpm-lock.yaml -not -path '*/node_modules/*' # exactly one, at the root +test -f ../probe3/packages/types/package.json && echo "types package: ok" +grep confirmModulesPurge ../probe3/pnpm-workspace.yaml # no match +(cd ../probe3 && mise run //apps/web:checklist) +``` + +- [ ] **Step 3 (criterion 4):** `bats tests/new-vite-react.bats` all PASS. + +- [ ] **Step 4 (criterion 5):** Run `./scripts/deploy-check.sh vite-react` and `./scripts/deploy-check.sh vite-react nestjs --db postgres`. Both PASS. + +- [ ] **Step 5 (criterion 6):** `./scripts/adapter-matrix.sh pull_request ""` lists `vite-react` in the Tier A matrix, and `git diff main -- .github/workflows/adapters.yml` is empty. + +- [ ] **Step 6 (criterion 7):** Re-run Task 2 Step 2's ADR check. + +- [ ] **Step 7:** Delete the probes: `rm -rf ../probe ../probe2 ../probe3`, and `docker image ls | grep deploy-check` to remove any local images the gate left. diff --git a/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md b/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md new file mode 100644 index 0000000..fac6978 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md @@ -0,0 +1,32 @@ +# Two `scaffold add` bugs — to fix next + +Date: 2026-10-14 +Status: found by reading the code while checking the withdrawn `opensocial` +request; neither is reproduced by a test yet. Each needs its own brainstorm, +fix and bats test. + +## 1. `scaffold add` repoints the compose `migrate` service + +`cmd_add` runs the service drivers for every added `api` or `app`. +`apply_service_compose_migrate` (`lib/service.sh`) then merges a `migrate` +service with `yq`'s `*`, which overwrites the existing `image` and `command`. +After `scaffold new --api nestjs --db postgres` then +`scaffold add apps/worker --adapter flask`, `migrate` runs the worker's image +with `alembic upgrade head`, not the api's Prisma migrations, and exits 0. +The comment above the call ("nothing generates that shape") is wrong. + +Proposed fix: keep an existing `migrate` service, so the first backend owns +migrations. Amend ADR-0022. + +## 2. A TypeScript app added outside the workspace globs loses its lockfile + +`joins_shared_workspace` (`scaffold`) checks only that the workspace has +`packages:` and the adapter is TypeScript, not that the new app's path matches +a glob (`apps/*`, `packages/*`, `docs`). For +`scaffold add services/x --adapter nestjs` in an all-TypeScript project, +`sync_workspace_lockfile` deletes `services/x/pnpm-lock.yaml`, the root +install skips the non-member app, and its standalone `Dockerfile` copies the +deleted lockfile. + +Proposed fix: also require `app_is_workspace_member "$project" "$rel"`; the +app then stays standalone with its own lockfile. Amend ADR-0018. diff --git a/docs/superpowers/specs/2026-10-14-vite-react-adapter-design.md b/docs/superpowers/specs/2026-10-14-vite-react-adapter-design.md new file mode 100644 index 0000000..fe1cc4d --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-vite-react-adapter-design.md @@ -0,0 +1,104 @@ +# `vite-react` adapter — design + +Date: 2026-10-14 +Origin: a request from the `opensocial` project for `vite-react` and `hono` +adapters. `opensocial` withdrew it; `vite-react` is kept as a general web +option. `hono`, its drivers and the mixed-project work are dropped. Two +`scaffold add` bugs found while checking the request are recorded separately +in `2026-10-14-scaffold-add-bugs.md`. + +## Goal + +A second `web` adapter for a React single-page app that builds to static +files, for projects that do not need Next.js's server. It pairs with any +existing api adapter, e.g. `scaffold new demo --web vite-react --api nestjs`. + +Success: + +1. `./scaffold lint` prints nothing and exits 0. +2. `./scaffold list --adapters` shows `vite-react`, role `web`, Tier A. +3. The runbook's "Verify" passes, including the second-TypeScript-app checks. +4. `bats tests/new-vite-react.bats` passes, including the assertion that the + adapter adds no tool to the project root `mise.toml`. +5. The built image answers `/health/live` 200, serves `index.html` for an + unknown path, serves `/config.json` from its `API_URL`, runs as non-root, + and contains no Node; `./scripts/deploy-check.sh vite-react` passes. +6. `.github/workflows/adapters.yml` picks it up via `ADAPTER_TIER`, no edit. +7. ADR 0025 passes `common/docs/scripts/check-adrs.mjs`. + +## Facts checked + +Run in a scratch directory on 2026-10-14: + +- create-vite is `9.2.1`. Its React templates default to oxlint; `--eslint` + restores ESLint (ADR-0008). +- `pnpm create vite@9 v --template react-ts --eslint --no-immediate + --no-interactive ` fetching `/config.json`. The template's `App` is left alone; this is the seam | +| `src/config.test.ts` | `loadConfig` returns a stubbed config, and rejects on a 404 | +| `mise.toml` | `install`, `format` = `prettier --check .`, `format-fix`, `lint` = `eslint . --max-warnings 0`, `check` = `tsc -b`, `test` = `vitest --run`, `build` = `vite build`, `ci-unit`, `checklist` | +| `Dockerfile`, `Dockerfile.workspace` | Node stage installs and runs `vite build`, carrying `# @SERVICE_SETUP@` (web takes no driver, but `apply_service_dockerfile` requires the anchor). Runtime: `caddy:2.11.7-alpine` by digest, `Caddyfile` to `/etc/caddy/Caddyfile`, `dist/` to `/srv`, `XDG_CONFIG_HOME`/`XDG_DATA_HOME` under `/tmp/caddy`, `USER nobody`, `EXPOSE 8080`, `HEALTHCHECK` with busybox `wget` on `http://127.0.0.1:8080/health/live`. No Node in the runtime image | +| `.dockerignore` | As `adapters/nextjs` | +| `.env.example` | `API_URL=` (runtime, read by Caddy; empty means same origin), and a comment: only `VITE_*` reach client code, baked in at build time, so per-environment values go through `/config.json` (ADR-0014) | +| `lefthook.fragment.yml` | `{}` | +| `.prettierignore` | `pnpm-lock.yaml`, `dist` | + +`{$API_URL}` is substituted when Caddy loads its config at container start, so +one image serves every environment. The value is not escaped; it comes from +the operator, not a visitor. + +## Tests + +`tests/new-vite-react.bats`, from `tests/new-nextjs.bats`'s shape: + +- generates `apps/web` with `index.html`, `Caddyfile`, `mise.toml`; +- adds no tool to the project root's `[tools]`; +- lints with ESLint, not oxlint; +- the generated app passes its own `checklist`; +- the `Caddyfile` has the health and config routes; the `Dockerfile` has one + Node stage; +- `--api vite-react` is rejected as a role mismatch. + +`tests/compose.bats` already enforces digests, `EXPOSE 8080` and the +liveness probe across every adapter Dockerfile. + +## Docs + +- ADR 0025: the `vite-react` runtime is Caddy serving static files, with + runtime config at `/config.json`. Alternatives: nginx-unprivileged, + build-time `VITE_*` only, same-origin `/api` behind a proxy. +- `README.md` and `docs/tour/08-adapters.md`: add the adapter row. + +## Out of scope + +`hono`, its service drivers, the mixed TypeScript + Python section, and the +two `scaffold add` bug fixes (separate spec). diff --git a/docs/tour/08-adapters.md b/docs/tour/08-adapters.md index 3920eed..27f276a 100644 --- a/docs/tour/08-adapters.md +++ b/docs/tour/08-adapters.md @@ -3,7 +3,7 @@ ## What it does - An adapter runs a framework's own generator (`ADAPTER_GENERATOR`), then overlays its files on the output (ADR-0003). -- Required files: `adapter.env`, `mise.toml`, `Dockerfile`, `.env.example`; `adapter.env` and `lefthook.fragment.yml` are read, never copied. +- Required files: `adapter.env`, `mise.toml`, `Dockerfile`, `.env.example`; `adapter.env`, `lefthook.fragment.yml` and `example.env.fragment` are read, never copied. - `ADAPTER_ROLE` (`web`, `api`, `app`) picks the directory: `apps/web`, `apps/api`, `apps/app`. - For an `api` or `app` adapter, each selected service's `drivers/.sh` runs, keyed on `ADAPTER_FAMILY`, and its Dockerfile block replaces the `# @SERVICE_SETUP@` anchor. - A `web` adapter takes no driver; the anchor is removed. diff --git a/lib/adapter.sh b/lib/adapter.sh index 0f5bcd9..044b23c 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -4,7 +4,7 @@ APPS_DIR="apps" # Sourced or merged by name, so never copied verbatim into the application. -ADAPTER_INTERNAL_FILES=(adapter.env lefthook.fragment.yml) +ADAPTER_INTERNAL_FILES=(adapter.env lefthook.fragment.yml example.env.fragment) # Cleared on every load, or a stale value is read as the next adapter's own. ADAPTER_OPTIONAL_VARS=( diff --git a/lib/service.sh b/lib/service.sh index bb75d84..5b4c38e 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -208,6 +208,11 @@ add_app_service() { merge_compose_fragment "$file" "$fragment" "the ${key} service" printf '\n%s=%s\n' "$port_var" "$port" >>"${project}/${EXAMPLE_ENV_FILE}" + # The root .env is what compose hands the container; an app's own + # .env.example reaches no deployed stack. + if [[ -f "${ADAPTER_DIR}/example.env.fragment" ]]; then + cat "${ADAPTER_DIR}/example.env.fragment" >>"${project}/${EXAMPLE_ENV_FILE}" + fi depend_on_project_services "$project" "$key" "$role" } diff --git a/tests/new-vite-react.bats b/tests/new-vite-react.bats new file mode 100644 index 0000000..8569e31 --- /dev/null +++ b/tests/new-vite-react.bats @@ -0,0 +1,77 @@ +setup() { + load 'helpers/setup' + WORKDIR="$(mktemp -d)" + PROJECT="${WORKDIR}/demo" +} + +teardown() { + remove_workdir "$WORKDIR" +} + +@test "vite-react generates an app at apps/web" { + run scaffold new "$PROJECT" --web vite-react + assert_ok + [ -f "${PROJECT}/apps/web/index.html" ] + [ -f "${PROJECT}/apps/web/Caddyfile" ] + [ -f "${PROJECT}/apps/web/mise.toml" ] +} + +@test "vite-react adds no tool to the project root" { + scaffold new "$PROJECT" --web vite-react + [ "$(yq -p toml -oy '.tools' "${PROJECT}/mise.toml")" = \ + "$(yq -p toml -oy '.tools' "${SCAFFOLD_ROOT}/common/mise.root.toml")" ] +} + +@test "vite-react lints with eslint, not oxlint" { + scaffold new "$PROJECT" --web vite-react + [ -f "${PROJECT}/apps/web/eslint.config.js" ] + run jq -r '.devDependencies.oxlint // "absent"' "${PROJECT}/apps/web/package.json" + [ "$output" = "absent" ] +} + +@test "the generated web app passes its own checklist" { + scaffold new "$PROJECT" --web vite-react + cd "$PROJECT" + run mise run //apps/web:checklist + assert_ok +} + +@test "the caddy runtime answers health and config without index.html" { + scaffold new "$PROJECT" --web vite-react + run grep -c 'handle /health/live\|handle /config.json' "${PROJECT}/apps/web/Caddyfile" + [ "$output" = "2" ] + run grep -c '^FROM node' "${PROJECT}/apps/web/Dockerfile" + [ "$output" = "1" ] +} + +@test "vite-react and nestjs share one workspace" { + scaffold new "$PROJECT" --web vite-react --api nestjs --db none + [ -f "${PROJECT}/packages/types/package.json" ] + run find "$PROJECT" -name pnpm-lock.yaml -not -path '*/node_modules/*' + [ "$output" = "${PROJECT}/pnpm-lock.yaml" ] + run grep -c -- '--filter web' "${PROJECT}/apps/web/Dockerfile" + [ "$output" = "1" ] +} + +@test "a role mismatch is rejected" { + run scaffold new "$PROJECT" --api vite-react + [ "$status" -eq 1 ] + [[ "$output" == *"adapter vite-react has role web"* ]] +} + +@test "the deployed stack's env lists API_URL for the web app" { + scaffold new "$PROJECT" --web vite-react + # compose feeds every container from the root .env, which install.sh builds + # from example.env; apps/web/.env.example reaches no container. + run grep -c '^API_URL=' "${PROJECT}/example.env" + [ "$output" = "1" ] + [ ! -e "${PROJECT}/apps/web/example.env.fragment" ] +} + +@test "the caddy runtime keeps a stale index.html out of browser caches" { + scaffold new "$PROJECT" --web vite-react + # a heuristically cached index.html asks for hashed assets a later deploy + # removed, and the SPA fallback answers them with HTML: a blank page. + run grep -c 'Cache-Control no-cache' "${PROJECT}/apps/web/Caddyfile" + [ "$output" = "1" ] +} diff --git a/tests/service.bats b/tests/service.bats index aee5530..9c703ee 100644 --- a/tests/service.bats +++ b/tests/service.bats @@ -145,7 +145,7 @@ setup() { @test "every adapter declares a framework family" { for adapter in "${SCAFFOLD_ROOT}"/adapters/*/; do - grep -Eq '^ADAPTER_FAMILY="(laravel|nest|next|flask)"$' "${adapter}adapter.env" || + grep -Eq '^ADAPTER_FAMILY="(laravel|nest|next|flask|vite)"$' "${adapter}adapter.env" || { echo "no ADAPTER_FAMILY in ${adapter}adapter.env" false