diff --git a/adapters/flask/README.md b/adapters/flask/README.md index e4c0827..41971fb 100644 --- a/adapters/flask/README.md +++ b/adapters/flask/README.md @@ -1,4 +1,4 @@ -# API +# @APP_NAME@ A Flask application. It implements this project's task contract, so every check runs the same way here as in any other config root: diff --git a/adapters/laravel-api/mise.toml b/adapters/laravel-api/mise.toml index 6705985..511bb2f 100644 --- a/adapters/laravel-api/mise.toml +++ b/adapters/laravel-api/mise.toml @@ -1,5 +1,5 @@ # composer only: mise's php backends compile from source and fail, so php comes -# from the system (ADR-0016). +# from the system (toolbox ADR-0016). [tools] "ubi:composer/composer" = "2.10.2" diff --git a/adapters/laravel-inertia/mise.toml b/adapters/laravel-inertia/mise.toml index 5faf34a..278803b 100644 --- a/adapters/laravel-inertia/mise.toml +++ b/adapters/laravel-inertia/mise.toml @@ -1,4 +1,4 @@ -# php comes from the system (ADR-0016); node builds the vite assets. +# php comes from the system (toolbox ADR-0016); node builds the vite assets. [tools] "ubi:composer/composer" = "2.10.2" node = "24.15.0" diff --git a/adapters/nestjs/Dockerfile b/adapters/nestjs/Dockerfile index 407cc5a..4e16a97 100644 --- a/adapters/nestjs/Dockerfile +++ b/adapters/nestjs/Dockerfile @@ -3,8 +3,8 @@ 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 unrs-resolver's postinstall build non-interactively. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox ADR-0017); without +# it pnpm refuses unrs-resolver's postinstall build non-interactively. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile COPY . . diff --git a/adapters/nestjs/Dockerfile.workspace b/adapters/nestjs/Dockerfile.workspace index 3081948..657762a 100644 --- a/adapters/nestjs/Dockerfile.workspace +++ b/adapters/nestjs/Dockerfile.workspace @@ -5,8 +5,8 @@ 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 unrs-resolver's postinstall build non-interactively. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox ADR-0017); without +# it pnpm refuses unrs-resolver'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 @@ -16,7 +16,7 @@ WORKDIR /app/apps/@APP_FILTER@ RUN pnpm exec nest build WORKDIR /app # At the root, where dependencies are hoisted; a purge prompt would abort -# without a TTY (ADR-0017). +# without a TTY (toolbox ADR-0017). RUN pnpm prune --prod --config.confirm-modules-purge=false FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS runtime diff --git a/adapters/nestjs/README.md b/adapters/nestjs/README.md index ae8e1f2..9255be3 100644 --- a/adapters/nestjs/README.md +++ b/adapters/nestjs/README.md @@ -1,4 +1,4 @@ -# API +# @APP_NAME@ A NestJS application. It implements this project's task contract, so every check runs the same way here as in any other config root: diff --git a/adapters/nestjs/adapter.env b/adapters/nestjs/adapter.env index f97f413..f613f29 100644 --- a/adapters/nestjs/adapter.env +++ b/adapters/nestjs/adapter.env @@ -8,6 +8,7 @@ ADAPTER_FAMILY="nest" ADAPTER_GENERATOR='pnpm dlx @nestjs/cli@11 new "$APP_DIR" --package-manager pnpm --skip-git' # `void bootstrap()` satisfies --max-warnings 0. app.module.ts is generator # output, so HealthModule is wired by sed; the grep pair fails a sed that missed. -ADAPTER_POST_GENERATE='sed -i "s/^bootstrap();$/void bootstrap();/" src/main.ts && sed -i "1i import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && sed -i "s/imports: \[\]/imports: [HealthModule]/" src/app.module.ts && { grep -q "import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && grep -q "imports: \[HealthModule\]" src/app.module.ts || { echo "post-generate: HealthModule wiring missing from src/app.module.ts; the nest generator likely changed its output format — update the sed patterns in ADAPTER_POST_GENERATE to match" >&2; exit 1; }; } && pnpm exec prettier --write .' +# main.ts falls back to the app's own PORT, not 3000, which next dev takes. +ADAPTER_POST_GENERATE='sed -i "s/^bootstrap();$/void bootstrap();/" src/main.ts && port="$(sed -n "s/^PORT=//p" .env.example)" && sed -i "s/process.env.PORT ?? 3000/process.env.PORT ?? ${port}/" src/main.ts && { [ -n "$port" ] && grep -q "process.env.PORT ?? ${port});" src/main.ts || { echo "post-generate: the PORT fallback is missing from src/main.ts; update the sed pattern in ADAPTER_POST_GENERATE to match" >&2; exit 1; }; } && sed -i "1i import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && sed -i "s/imports: \[\]/imports: [HealthModule]/" src/app.module.ts && { grep -q "import { HealthModule } from '"'"'./health/health.module'"'"';" src/app.module.ts && grep -q "imports: \[HealthModule\]" src/app.module.ts || { echo "post-generate: HealthModule wiring missing from src/app.module.ts; the nest generator likely changed its output format — update the sed patterns in ADAPTER_POST_GENERATE to match" >&2; exit 1; }; } && pnpm exec prettier --write .' ADAPTER_LIVENESS_PATH="/health/live" ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/nextjs/.env.example b/adapters/nextjs/.env.example index d658484..3bda8ab 100644 --- a/adapters/nextjs/.env.example +++ b/adapters/nextjs/.env.example @@ -1 +1,2 @@ -NEXT_PUBLIC_API_URL=http://localhost:3001 +# Only NEXT_PUBLIC_* variables reach client code, and next build inlines them: +# anything that differs per environment must be read on the server. diff --git a/adapters/nextjs/Dockerfile b/adapters/nextjs/Dockerfile index 0eb026d..9867693 100644 --- a/adapters/nextjs/Dockerfile +++ b/adapters/nextjs/Dockerfile @@ -3,8 +3,8 @@ FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS deps WORKDIR /app RUN corepack enable -# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm -# refuses unrs-resolver's postinstall build non-interactively. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox ADR-0017); without +# it pnpm refuses unrs-resolver's postinstall build non-interactively. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile diff --git a/adapters/nextjs/Dockerfile.workspace b/adapters/nextjs/Dockerfile.workspace index dbe30b1..e5d56b4 100644 --- a/adapters/nextjs/Dockerfile.workspace +++ b/adapters/nextjs/Dockerfile.workspace @@ -5,8 +5,8 @@ FROM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS deps WORKDIR /app RUN corepack enable -# pnpm-workspace.yaml holds the allowBuilds policy (ADR-0017); without it pnpm -# refuses unrs-resolver's postinstall build non-interactively. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox ADR-0017); without +# it pnpm refuses unrs-resolver'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 diff --git a/adapters/nextjs/next.config.ts b/adapters/nextjs/next.config.ts index 4f07a8f..f97d180 100644 --- a/adapters/nextjs/next.config.ts +++ b/adapters/nextjs/next.config.ts @@ -3,6 +3,12 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // the Dockerfile copies .next/standalone, which next emits only when asked output: 'standalone', + // create-next-app --tailwind wires Tailwind in the file this one replaces + turbopack: { + rules: { + '*.css': { loaders: ['@tailwindcss/turbopack'], as: '*.css' }, + }, + }, }; export default nextConfig; diff --git a/adapters/vite-react/.env.example b/adapters/vite-react/.env.example index 85c8244..f69b840 100644 --- a/adapters/vite-react/.env.example +++ b/adapters/vite-react/.env.example @@ -1,3 +1,4 @@ # 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). +# anything that differs per environment belongs in /config.json +# (toolbox ADR-0014). diff --git a/adapters/vite-react/Caddyfile b/adapters/vite-react/Caddyfile index 102cd86..63e8bc9 100644 --- a/adapters/vite-react/Caddyfile +++ b/adapters/vite-react/Caddyfile @@ -10,7 +10,7 @@ } # {$API_URL} is read when Caddy loads this file at container start, so one - # image serves every environment (ADR-0014). Unset means same origin. + # image serves every environment (toolbox ADR-0014). Unset means same origin. handle /config.json { header Content-Type application/json header Cache-Control no-store diff --git a/adapters/vite-react/Dockerfile b/adapters/vite-react/Dockerfile index e6619a4..e2e1e22 100644 --- a/adapters/vite-react/Dockerfile +++ b/adapters/vite-react/Dockerfile @@ -3,8 +3,8 @@ 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. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox 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 . . diff --git a/adapters/vite-react/Dockerfile.workspace b/adapters/vite-react/Dockerfile.workspace index 220eb80..7f00a82 100644 --- a/adapters/vite-react/Dockerfile.workspace +++ b/adapters/vite-react/Dockerfile.workspace @@ -5,8 +5,8 @@ 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. +# pnpm-workspace.yaml holds the allowBuilds policy (toolbox 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 diff --git a/adapters/vite-react/src/config.ts b/adapters/vite-react/src/config.ts index d638b96..7a9a61a 100644 --- a/adapters/vite-react/src/config.ts +++ b/adapters/vite-react/src/config.ts @@ -4,7 +4,7 @@ export interface RuntimeConfig { // 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). +// (toolbox ADR-0014). export async function loadConfig(): Promise { const response = await fetch("/config.json"); if (!response.ok) { diff --git a/common/.github/workflows/build.yml b/common/.github/workflows/build.yml index 164aee0..5cdff72 100644 --- a/common/.github/workflows/build.yml +++ b/common/.github/workflows/build.yml @@ -12,8 +12,9 @@ jobs: permissions: contents: read packages: write - # `@v1` floats on purpose: one fix reaches every project (ADR-0005). + # `@v1` floats on purpose: one fix reaches every project (toolbox ADR-0005). uses: you/.github/.github/workflows/app-build.yml@v1 # zizmor: ignore[unpinned-uses,ref-confusion] with: - # {image, context, dockerfile} per application, written by scaffold (ADR-0022). + # {image, context, dockerfile} per application, written by scaffold + # (toolbox ADR-0022). images: "[]" diff --git a/common/.github/workflows/ci.yml b/common/.github/workflows/ci.yml index 38334af..d9e4e0b 100644 --- a/common/.github/workflows/ci.yml +++ b/common/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: permissions: contents: read pull-requests: read - # `@v1` floats on purpose: one fix reaches every project (ADR-0005). + # `@v1` floats on purpose: one fix reaches every project (toolbox ADR-0005). uses: you/.github/.github/workflows/app-ci.yml@v1 # zizmor: ignore[unpinned-uses,ref-confusion] with: roots: '["docs"]' diff --git a/common/.github/workflows/docs.yml b/common/.github/workflows/docs.yml index 8bb3b11..4f2d18f 100644 --- a/common/.github/workflows/docs.yml +++ b/common/.github/workflows/docs.yml @@ -9,5 +9,5 @@ jobs: # what app-docs's jobs need; `permissions: {}` above leaves nothing to inherit. permissions: contents: read - # `@v1` floats on purpose: one fix reaches every project (ADR-0005). + # `@v1` floats on purpose: one fix reaches every project (toolbox ADR-0005). uses: you/.github/.github/workflows/app-docs.yml@v1 # zizmor: ignore[unpinned-uses,ref-confusion] diff --git a/common/.github/workflows/release.yml b/common/.github/workflows/release.yml index 288886c..7f0f6cc 100644 --- a/common/.github/workflows/release.yml +++ b/common/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: issues: write packages: write pull-requests: write - # `@v1` floats on purpose: one fix reaches every project (ADR-0005). + # `@v1` floats on purpose: one fix reaches every project (toolbox ADR-0005). uses: you/.github/.github/workflows/app-release.yml@v1 # zizmor: ignore[unpinned-uses,ref-confusion] # Without an app token, checks on the release PR sit at "Action required" # and expire red. Named, not `inherit`: the callee reads only these two. @@ -22,5 +22,6 @@ jobs: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: - # {image, context, dockerfile} per application, written by scaffold (ADR-0022). + # {image, context, dockerfile} per application, written by scaffold + # (toolbox ADR-0022). images: "[]" diff --git a/common/.github/workflows/security.yml b/common/.github/workflows/security.yml index 981282b..0a30364 100644 --- a/common/.github/workflows/security.yml +++ b/common/.github/workflows/security.yml @@ -14,5 +14,5 @@ jobs: security-events: write pull-requests: read actions: read - # `@v1` floats on purpose: one fix reaches every project (ADR-0005). + # `@v1` floats on purpose: one fix reaches every project (toolbox ADR-0005). uses: you/.github/.github/workflows/app-security.yml@v1 # zizmor: ignore[unpinned-uses,ref-confusion] diff --git a/common/compose.yaml b/common/compose.yaml index 8fb3f81..034c11b 100644 --- a/common/compose.yaml +++ b/common/compose.yaml @@ -1,5 +1,5 @@ -# The stack clients run, attached to every release (ADR-0014). Every variable has -# a default so the file validates before install.sh writes .env. +# The stack clients run, attached to every release (toolbox ADR-0021). Every +# variable has a default so the file validates before install.sh writes .env. name: app services: {} diff --git a/common/docs/.vitepress/config.ts b/common/docs/.vitepress/config.ts index d083ced..8ad3126 100644 --- a/common/docs/.vitepress/config.ts +++ b/common/docs/.vitepress/config.ts @@ -3,10 +3,10 @@ import { defineConfig } from 'vitepress'; export default defineConfig({ title: '@PROJECT_TITLE@', description: 'Project documentation', - head: [['link', { rel: 'icon', href: '/logo.png' }]], + head: [['link', { rel: 'icon', href: '/logo.svg' }]], ignoreDeadLinks: false, themeConfig: { - logo: '/logo.png', + logo: '/logo.svg', sidebar: [ { text: 'Guide', diff --git a/common/docs/.vitepress/theme/vendor/escrcpy/NOTICE b/common/docs/.vitepress/theme/vendor/escrcpy/NOTICE index c2f3d1a..336a3f5 100644 --- a/common/docs/.vitepress/theme/vendor/escrcpy/NOTICE +++ b/common/docs/.vitepress/theme/vendor/escrcpy/NOTICE @@ -12,7 +12,3 @@ Copied at commit 1e87397da0535b825d5e2e87a53e08fa3896f4a9: Unmodified. Prettier is told to skip this directory (see docs/.prettierignore) so that reformatting can never turn the copy into a derivative work by accident. - -The project logo at docs/public/logo.png is also escrcpy's. It is a placeholder -for this project's own mark, not an asset the Apache licence grants: section 6 -withholds trademark rights. Replace it before the docs site is published. diff --git a/common/docs/getting-started.md b/common/docs/getting-started.md index 13b6589..07a9dc7 100644 --- a/common/docs/getting-started.md +++ b/common/docs/getting-started.md @@ -9,8 +9,5 @@ mise run dev # the compose stack Before opening a pull request, run `mise run checklist`. It runs exactly what CI runs. -The docs site ships a placeholder logo at `docs/public/logo.png`, borrowed from -[escrcpy](https://github.com/viarotel-org/escrcpy) along with the theme colours -in `docs/.vitepress/theme/vendor/escrcpy/NOTICE`. A logo is a trademark, not -something the Apache licence hands over — replace it with this project's own -mark before the site is published. +The docs site ships a placeholder logo at `docs/public/logo.svg`. Replace it +with this project's own mark. diff --git a/common/docs/index.md b/common/docs/index.md index eb41467..72a0093 100644 --- a/common/docs/index.md +++ b/common/docs/index.md @@ -4,7 +4,7 @@ hero: name: "@PROJECT_TITLE@" tagline: Project documentation image: - src: /logo.png + src: /logo.svg alt: "@PROJECT_TITLE@" actions: - theme: brand diff --git a/common/docs/public/logo.png b/common/docs/public/logo.png deleted file mode 100644 index 4001947..0000000 Binary files a/common/docs/public/logo.png and /dev/null differ diff --git a/common/docs/public/logo.svg b/common/docs/public/logo.svg new file mode 100644 index 0000000..e8b76a2 --- /dev/null +++ b/common/docs/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/common/docs/scripts/check-paths.mjs b/common/docs/scripts/check-paths.mjs index 1bfb1cb..8f13625 100644 --- a/common/docs/scripts/check-paths.mjs +++ b/common/docs/scripts/check-paths.mjs @@ -1,6 +1,7 @@ +import { execFileSync } from "node:child_process"; import { readdir, readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { extname, join, resolve } from "node:path"; +import { basename, extname, join, resolve } from "node:path"; const PROJECT_ROOT = resolve(import.meta.dirname, "..", ".."); // backticked strings that look like repository paths @@ -14,20 +15,36 @@ const SKIP_DIRS = new Set(["node_modules", ".git", ".vitepress"]); // still walks apps/ const MARKDOWN_SKIP_DIRS = new Set([...SKIP_DIRS, "apps"]); -async function filesMatching(dir, matches, skipDirs = SKIP_DIRS) { - const entries = await readdir(dir, { withFileTypes: true }); - const files = await Promise.all( - entries.map((entry) => { - const path = join(dir, entry.name); - if (entry.isDirectory()) { - return skipDirs.has(entry.name) - ? [] - : filesMatching(path, matches, skipDirs); - } - return matches(entry.name) ? [path] : []; - }), - ); - return files.flat(); +// what git would commit: scratch notes and build output are not docs +function listProjectFiles() { + try { + return execFileSync( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + { + cwd: PROJECT_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ) + .split("\0") + .filter(Boolean); + } catch (error) { + console.error( + `check-paths reads the file list from git, and ${PROJECT_ROOT} is not a git checkout: ${error.message}`, + ); + process.exit(1); + } +} + +const projectFiles = listProjectFiles(); + +function filesMatching(matches, skipDirs = SKIP_DIRS) { + return projectFiles + .filter((path) => !path.split("/").some((part) => skipDirs.has(part))) + .filter((path) => matches(basename(path))) + .map((path) => join(PROJECT_ROOT, path)) + .filter((path) => existsSync(path)); } function missingPaths(content) { @@ -52,8 +69,7 @@ function missingAdrReferences(content, shippedNumbers) { const failures = []; -for (const file of await filesMatching( - PROJECT_ROOT, +for (const file of filesMatching( (name) => name.endsWith(".md"), MARKDOWN_SKIP_DIRS, )) { @@ -63,7 +79,7 @@ for (const file of await filesMatching( } const shippedNumbers = await shippedAdrNumbers(); -const adrScanFiles = await filesMatching(PROJECT_ROOT, (name) => +const adrScanFiles = filesMatching((name) => ADR_SCAN_EXTENSIONS.has(extname(name)), ); for (const file of adrScanFiles) { diff --git a/common/install.sh b/common/install.sh index 968f5d3..6f39a16 100755 --- a/common/install.sh +++ b/common/install.sh @@ -16,7 +16,8 @@ RepoSlug="${RepoSlug%/releases/latest/download}" # Matched on the value, not a *_PASSWORD name, so RABBITMQ_DEFAULT_PASS counts. PasswordPlaceholder='changeme' PasswordBytes=32 -PasswordLength=24 +# Auth and session secrets commonly reject anything shorter. +PasswordLength=32 # jq, not grep: the uploader's id follows the asset's name, so "the next id # after the name" fetches a different, valid object. @@ -133,7 +134,8 @@ generate_service_passwords() { if [[ "$name" == "APP_KEY" ]]; then password="base64:$(head -c "$PasswordBytes" /dev/urandom | base64)" else - password="$(head -c "$PasswordBytes" /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$PasswordLength")" + # Twice the length in bytes: tr drops base64's `+` and `/`. + password="$(head -c "$((PasswordLength * 2))" /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "$PasswordLength")" fi # `|`: a base64 value can contain `/`. sed -i.bak "s|^${name}=${PasswordPlaceholder}\$|${name}=${password}|" "$file" @@ -177,7 +179,7 @@ compose_has_service() { grep -qx "$service" <<<"$services" } -# ADR-0014 forbids migrations from an entrypoint, so they run here. +# toolbox ADR-0014 forbids migrations from an entrypoint, so they run here. run_migrations() { # `config --services` omits a profiled service unless the profile is named. if compose_has_service migrate --profile migrate; then @@ -192,7 +194,8 @@ run_migrations() { fi } -# ADR-0022: one line per application, from .env, so a changed port shows. +# toolbox ADR-0022: one line per application, from .env, so a changed port +# shows. print_running_apps() { local name port while IFS='=' read -r name port; do diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 4305654..a71e56c 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -42,8 +42,7 @@ Excluded, and why: both resolve exactly one upstream clone. Its provenance travels with the files instead, in that directory's own `NOTICE` — source repository, commit, file-by-file origin and licence — which is what a reader who finds the - directory will actually look at. `common/docs/public/logo.png` comes from the same - repository and the same `NOTICE` covers it. + directory will actually look at. - **`docs/decisions/`** (this repository's own, at the root — not `common/docs/decisions/`, which ships and is covered below). This is this project's own record, not files copied from upstream. diff --git a/docs/decisions/0022-one-image-per-application.md b/docs/decisions/0022-one-image-per-application.md index e3d08bc..69b6526 100644 --- a/docs/decisions/0022-one-image-per-application.md +++ b/docs/decisions/0022-one-image-per-application.md @@ -87,10 +87,10 @@ than silently preferring one. - `install.sh` reports one URL per application rather than one for the project. - The migrate service runs the image of the first application whose role - takes a database driver. A project with two backends would need a - migration story per backend; nothing generates that shape today, and - inventing one now would be the speculative work this repository keeps - declining. + takes a database driver, and a later `scaffold add` leaves it there. A + second backend's migrations are its own to run; nothing generates a + migration story per backend today, and inventing one now would be the + speculative work this repository keeps declining. - `v1` in `you/.github` moves, which reaches every generated project at once. Accepted under ADR-0005's terms and for the reason recorded there; the change is additive to the inputs, and the smoke path was run against a real diff --git a/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md b/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md index fac6978..04017f5 100644 --- a/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md +++ b/docs/superpowers/specs/2026-10-14-scaffold-add-bugs.md @@ -18,6 +18,9 @@ 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. +Fixed on 2026-10-16 as proposed, with a unit test in `tests/service.bats` and +an integration test in `tests/add-app.bats`. + ## 2. A TypeScript app added outside the workspace globs loses its lockfile `joins_shared_workspace` (`scaffold`) checks only that the workspace has diff --git a/lib/adapter.sh b/lib/adapter.sh index 044b23c..b2caa2a 100644 --- a/lib/adapter.sh +++ b/lib/adapter.sh @@ -109,6 +109,30 @@ substitute_workspace_filter() { rm -f "${file}.bak" } +substitute_app_name() { + local -r dest="$1" + local -r file="${dest}/README.md" + + [[ -f "$file" ]] || return 0 + + sed -i.bak "s|@APP_NAME@|$(basename "$dest")|g" "$file" + rm -f "${file}.bak" +} + +# The host port compose allocated this app, so no two dev servers collide. +assign_app_port() { + local -r project="$1" dest="$2" rel="$3" + local -r file="${dest}/.env.example" + local port + + grep -q '^PORT=' "$file" 2>/dev/null || return 0 + + port="$(sed -n "s/^$(app_port_variable "$rel")=//p" "${project}/${EXAMPLE_ENV_FILE}")" + [[ -n "$port" ]] || die "no port allocated for ${rel} in ${EXAMPLE_ENV_FILE}" + sed -i.bak "s/^PORT=.*/PORT=${port}/" "$file" + rm -f "${file}.bak" +} + # Directories merge rather than replace: the generator already created `src/`, # and `cp -R src dest/src` would nest it. copy_adapter_files() { @@ -145,6 +169,8 @@ apply_adapter() { assert_workspace_filter_name "$dest" copy_adapter_files "$dest" substitute_workspace_filter "$dest" + substitute_app_name "$dest" + assign_app_port "$project" "$dest" "$rel" configure_adapter_app "$dest" "$rel" wire_adapter_services "$dest" "$project" diff --git a/lib/pnpm.sh b/lib/pnpm.sh index c54eea9..e5a0b5d 100644 --- a/lib/pnpm.sh +++ b/lib/pnpm.sh @@ -46,6 +46,8 @@ app_is_workspace_member() { local glob [[ -f "$workspace_file" ]] || return 1 + # pnpm adopts a matching directory only when it has a package.json. + [[ -f "${project}/${rel}/package.json" ]] || return 1 while IFS= read -r glob; do [[ -n "$glob" ]] || continue diff --git a/lib/service.sh b/lib/service.sh index 5b4c38e..6b412a8 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -360,10 +360,14 @@ apply_service_compose_service() { apply_service_compose_migrate() { local -r project="$1" service="$2" env_block="$3" command="$4" local -r file="${project}/${COMPOSE_FILE}" - local image block + local image block has_migrate [[ -n "$command" ]] || return 0 [[ -f "$file" ]] || die "no ${COMPOSE_FILE} in ${project}" + # The first backend owns migrations (ADR-0022). + has_migrate="$(yq '.services | has("migrate")' "$file")" || + die "could not read the services out of ${file}" + [[ "$has_migrate" == "false" ]] || return 0 image="$(yq ".services.\"${service}\".image" "$file")" || die "could not read ${service}'s image out of ${file}" @@ -472,7 +476,6 @@ apply_service_drivers() { key="$(app_service_key "$app")" apply_service_dockerfile "$app" "${block%$'\n'}" apply_service_compose_env "$project" "$key" "${env_block%$'\n'}" - # Runs the first driven application's image; a second backend would need its - # own migrate service, and nothing generates that shape (ADR-0022). + # A later backend's migrations are its own to run (ADR-0022). apply_service_compose_migrate "$project" "$key" "${env_block%$'\n'}" "${migrate_block%$'\n'}" } diff --git a/lib/update.sh b/lib/update.sh index 9c4b001..eed1c35 100644 --- a/lib/update.sh +++ b/lib/update.sh @@ -72,7 +72,7 @@ substitute_placeholders() { ) if [[ -n "$rel" ]]; then filter="$(app_service_key "$rel")" - rules+=(-e "s|@APP_ROOT@|${rel}/|g" -e "s|@APP_FILTER@|${filter}|g") + rules+=(-e "s|@APP_ROOT@|${rel}/|g" -e "s|@APP_FILTER@|${filter}|g" -e "s|@APP_NAME@|${filter}|g") fi sed "${rules[@]}" @@ -81,7 +81,7 @@ substitute_placeholders() { common_patch() { local -r project="$1" - git -C "$SCAFFOLD_ROOT" diff "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ + git -C "$SCAFFOLD_ROOT" diff --binary "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ common/ "${COMMON_PATCH_EXCLUDES[@]}" | rewrite_patch_paths 'common/' '' | substitute_placeholders "$project" @@ -109,9 +109,9 @@ adapter_patch() { done { - git -C "$SCAFFOLD_ROOT" diff "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ + git -C "$SCAFFOLD_ROOT" diff --binary "${SCAFFOLD_UPDATE_FROM}..HEAD" -- \ "${dir}/" "${excludes[@]}" - git -C "$SCAFFOLD_ROOT" diff "${SCAFFOLD_UPDATE_FROM}..HEAD" -- "${dir}/${kept}" | + git -C "$SCAFFOLD_ROOT" diff --binary "${SCAFFOLD_UPDATE_FROM}..HEAD" -- "${dir}/${kept}" | rewrite_patch_paths "${dir}/${kept}" "${dir}/Dockerfile" } | rewrite_patch_paths "${dir}/" "${rel}/" | diff --git a/tests/add-app.bats b/tests/add-app.bats index 40bd8c8..710d516 100644 --- a/tests/add-app.bats +++ b/tests/add-app.bats @@ -129,3 +129,50 @@ teardown() { run grep -c 'COPY package.json pnpm-lock.yaml pnpm-workspace.yaml' "${mixed}/apps/web/Dockerfile" [ "$output" = "1" ] } + +@test "a python app under a workspace glob builds from its own directory" { + # apps/* matches apps/worker, but pnpm adopts no directory without a + # package.json; flask's Dockerfile copies uv.lock from its build context. + cd "$PROJECT" + run scaffold add apps/worker --adapter flask + assert_ok + + run yq -r '.jobs.build.with.images' .github/workflows/build.yml + assert_ok + run jq -r '.[] | select(.dockerfile == "apps/worker/Dockerfile") | .context' <<<"$output" + [ "$output" = apps/worker ] || { + echo "context is ${output}" + false + } +} + +@test "a second backend leaves migrate on the first backend's image" { + cd "$PROJECT" + local before + before="$(yq -r '.services.migrate.image' compose.yaml)" + run scaffold add apps/render --adapter nestjs + assert_ok + run yq -r '.services.migrate.image' compose.yaml + [ "$output" = "$before" ] || { + echo "migrate moved from ${before} to ${output}" + false + } +} + +@test "each nest app gets its own dev port, and none falls back to next's 3000" { + cd "$PROJECT" + run scaffold add apps/render --adapter nestjs + assert_ok + + local api render + api="$(sed -n 's/^PORT=//p' apps/api/.env.example)" + render="$(sed -n 's/^PORT=//p' apps/render/.env.example)" + [ -n "$api" ] && [ "$api" != "$render" ] || { + echo "api PORT=${api}, render PORT=${render}" + false + } + run grep -c "process.env.PORT ?? ${render})" apps/render/src/main.ts + [ "$output" = 1 ] + run grep -q '^# render$' apps/render/README.md + assert_ok +} diff --git a/tests/docs.bats b/tests/docs.bats index 9726fe5..9ed05aa 100644 --- a/tests/docs.bats +++ b/tests/docs.bats @@ -93,7 +93,17 @@ EOF assert_ok } -@test "the vendored theme ships whole: css, licence, notice and logo" { +@test "the path check skips what git ignores" { + mkdir -p "${PROJECT}/.notes" + # shellcheck disable=SC2016 # literal markdown backticks, not expanded + echo 'See `does/not/exist.md`.' >"${PROJECT}/.notes/todo.md" + echo '.notes/' >>"${PROJECT}/.gitignore" + cd "${PROJECT}/docs" + run node scripts/check-paths.mjs + assert_ok +} + +@test "the vendored theme ships whole: css, licence and notice" { local vendor="${PROJECT}/docs/.vitepress/theme/vendor/escrcpy" file for file in LICENSE NOTICE rainbow.css vars.css; do [ -f "${vendor}/${file}" ] || { @@ -101,7 +111,6 @@ EOF false } done - [ -f "${PROJECT}/docs/public/logo.png" ] # reformatting the copy would make it a modified file under section 4(b); # the exemption is the only thing standing between prettier and that. run grep -qx '.vitepress/theme/vendor' "${PROJECT}/docs/.prettierignore" @@ -120,7 +129,7 @@ EOF assert_ok run grep -rq 'scrollbar-gutter' docs/.vitepress/dist/assets assert_ok - run grep -q 'logo.png' docs/.vitepress/dist/index.html + run grep -q 'logo.svg' docs/.vitepress/dist/index.html assert_ok } diff --git a/tests/documentation.bats b/tests/documentation.bats index a87f69c..1abd3d4 100644 --- a/tests/documentation.bats +++ b/tests/documentation.bats @@ -56,3 +56,13 @@ setup() { done" [ -z "$output" ] } + +@test "a shipped file cites the toolbox's ADRs as the toolbox's" { + # A generated project numbers its own ADRs from 0001; a bare ADR-0014 in a + # file it owns reads as one of them. + run grep -rnP '(?.env + run generate_service_passwords .env + assert_ok + local name value + while IFS='=' read -r name value; do + ((${#value} >= 32)) || { + echo "${name} is ${#value} characters" + false + } + done <.env +} diff --git a/tests/new-flask.bats b/tests/new-flask.bats index 584a7d9..307d195 100644 --- a/tests/new-flask.bats +++ b/tests/new-flask.bats @@ -77,3 +77,9 @@ teardown() { run grep -q '@SERVICE_SETUP@' "${PROJECT}/apps/api/Dockerfile" [ "$status" -ne 0 ] } + +@test "the flask app's README is titled after the app" { + scaffold new "$PROJECT" --api flask + run head -1 "${PROJECT}/apps/api/README.md" + [ "$output" = "# api" ] +} diff --git a/tests/new-nextjs.bats b/tests/new-nextjs.bats index 80ac60f..b7557fd 100644 --- a/tests/new-nextjs.bats +++ b/tests/new-nextjs.bats @@ -47,3 +47,18 @@ teardown() { run grep "output: 'standalone'" "${PROJECT}/apps/web/next.config.ts" assert_ok } + +@test "tailwind compiles in the web app's build" { + # create-next-app wires Tailwind into the next.config.ts the adapter replaces; + # without the loader, next build only warns and ships @apply uncompiled. + scaffold new "$PROJECT" --web nextjs + cd "${PROJECT}/apps/web" + printf '.probe { @apply flex; }\n' >>src/app/globals.css + run mise exec -- pnpm exec next build + assert_ok + run grep -l '@apply' .next/static/chunks/*.css + [ "$status" -eq 1 ] || { + echo "uncompiled @apply in ${output}" + false + } +} diff --git a/tests/pnpm.bats b/tests/pnpm.bats index bf4ddf4..b247a07 100644 --- a/tests/pnpm.bats +++ b/tests/pnpm.bats @@ -27,3 +27,14 @@ EOF [ "$status" -ne 0 ] [ ! -e "$ran_marker" ] } + +@test "only a directory with a package.json is a workspace member" { + printf 'packages:\n - apps/*\n' >"${WORKDIR}/${WORKSPACE_FILE}" + mkdir -p "${WORKDIR}/apps/web" "${WORKDIR}/apps/worker" + printf '{}\n' >"${WORKDIR}/apps/web/package.json" + printf '[project]\n' >"${WORKDIR}/apps/worker/pyproject.toml" + + app_is_workspace_member "$WORKDIR" apps/web + run app_is_workspace_member "$WORKDIR" apps/worker + [ "$status" -eq 1 ] +} diff --git a/tests/service.bats b/tests/service.bats index 9c703ee..b38839f 100644 --- a/tests/service.bats +++ b/tests/service.bats @@ -952,3 +952,21 @@ _password_literal_report() { false } } + +@test "a second backend leaves migrate on the first one's image" { + local project="${BATS_TEST_TMPDIR}/two-backends" + mkdir -p "$project" + printf 'services:\n api:\n image: ghcr.io/acme/demo-api:x\n render:\n image: ghcr.io/acme/demo-render:x\n' \ + >"${project}/compose.yaml" + + apply_service_compose_migrate "$project" api '' 'command: ["migrate"]' + apply_service_compose_migrate "$project" render '' 'command: ["other"]' + + run yq -r '.services.migrate.image' "${project}/compose.yaml" + [ "$output" = ghcr.io/acme/demo-api:x ] || { + echo "migrate runs ${output}" + false + } + run yq -r '.services.migrate.command[0]' "${project}/compose.yaml" + [ "$output" = migrate ] +} diff --git a/tests/update.bats b/tests/update.bats index ed0c7d6..8afb018 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -232,3 +232,23 @@ EOF } [[ "$output" == *'apps/api/Dockerfile'* ]] } + +@test "update carries a binary file's change" { + local box from + box="$(copy_toolbox)" + printf '\x89PNG\r\n\x1a\n\x00old' >"${box}/common/mark.png" + git -C "$box" init -q -b main + _commit "$box" "before" + from="$(git -C "$box" rev-parse HEAD)" + printf '\x89PNG\r\n\x1a\n\x00new' >"${box}/common/mark.png" + _commit "$box" "after" + + local project="${WORKDIR}/binary" + _fixture_project "$project" "$from" + printf '\x89PNG\r\n\x1a\n\x00old' >"${project}/mark.png" + _commit "$project" "the mark it was generated with" + + run "${box}/scaffold" update "$project" + assert_ok + cmp -s "${box}/common/mark.png" "${project}/mark.png" +}