From 52481c8b85049f16a6b2be458f7d1aadd273f46d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:45:41 +0000 Subject: [PATCH] Speak the local user's language on /setup, and say nothing after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard runs just as well against a checkout on someone's own machine as against a deployment, but every word on the last screen assumed a dashboard and a redeploy — advice a local user cannot follow. `detectHost` now recognises `local`: a dev server always, and a production build reached over loopback, which is `next start` or the compose stack. The `Host` header is the only thing separating that from a self-hosted server on a real domain, so the page passes it in. Locally the closing instructions become the `.env` file and a restart, the database step offers a file URL, and the token field stops insisting one is required. The status page is a different problem: once setup has run, anyone who can reach the site can read it. It no longer prints the site id, and the app check no longer names the id it expected — it says what is wrong without handing over a value. In its place is what the reader actually wants to know at that point: this route has done its job and can be deleted. /setup is also disallowed in robots.txt now. The page already sent `noindex`, but a crawler that never fetches it never sees that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YK3DzqnUsubgdN3NLgp8D5 --- src/app/(setup)/layout.tsx | 14 +++++- src/app/(setup)/lib/host.ts | 49 +++++++++++++++--- src/app/(setup)/lib/status.ts | 7 +-- src/app/(setup)/setup/SuccessScreen.tsx | 60 +++++++++++++++++----- src/app/(setup)/setup/page.tsx | 47 +++++++++++++----- src/app/(setup)/setup/wizard.tsx | 42 ++++++++++++---- src/app/robots.ts | 11 ++++- tests/int/robots.int.spec.ts | 7 +++ tests/int/setup-host.int.spec.ts | 66 +++++++++++++++++++++---- 9 files changed, 246 insertions(+), 57 deletions(-) diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index 01e0c02..c2e256f 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -1,9 +1,21 @@ import type { Metadata, Viewport } from 'next' import './setup.css' +/** + * Setup is never a page anyone should reach from a search result: before it runs + * it accepts credentials, and after it runs it reports on the site's own + * configuration. `nocache` and the explicit Googlebot block matter as much as + * `index: false` — without them a page that was crawled once can stay in the + * index as a cached copy. + */ export const metadata: Metadata = { title: 'Set up your site | ChaiBuilder', - robots: { index: false, follow: false }, + robots: { + index: false, + follow: false, + nocache: true, + googleBot: { index: false, follow: false, noimageindex: true }, + }, } /** diff --git a/src/app/(setup)/lib/host.ts b/src/app/(setup)/lib/host.ts index a898337..83682fd 100644 --- a/src/app/(setup)/lib/host.ts +++ b/src/app/(setup)/lib/host.ts @@ -2,12 +2,14 @@ * Which host this deployment runs on, and a link to where its environment * variables are edited. * - * Setup ends by asking the user to paste variables into their host and redeploy, - * so detecting the host turns "on Vercel do this, on Netlify do that" into one - * set of instructions for the place they are actually standing. + * Setup ends by asking the user to put variables somewhere and restart, so + * detecting the host turns "on Vercel do this, on Netlify do that, on your own + * machine do the other" into one set of instructions for the place they are + * actually standing. `local` is the same wizard against a `.env` file: nothing + * to paste into a dashboard, nothing to redeploy. */ -export type Host = 'vercel' | 'netlify' | 'unknown' +export type Host = 'vercel' | 'netlify' | 'local' | 'unknown' /** * Vercel's docs link to their own settings pages through a redirect that @@ -26,6 +28,25 @@ const VERCEL_ENV_SETTINGS = '&title=' + encodeURIComponent('Go to Environment Variables') +/** Hostnames that only ever mean "the machine the browser is running on". */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1']) + +/** + * The hostname out of a `Host` header, without its port. + * + * IPv6 literals are the awkward case: bracketed (`[::1]:3000`) the port is + * whatever follows the closing bracket, bare (`::1`) every colon belongs to the + * address. Only a single colon can safely be read as a port separator. + */ +function hostnameOf(requestHost: string): string { + const value = requestHost.trim().toLowerCase() + if (value.startsWith('[')) { + const close = value.indexOf(']') + return close === -1 ? value.slice(1) : value.slice(1, close) + } + return value.split(':').length === 2 ? value.split(':')[0] : value +} + /** * `VERCEL` is set at build and at runtime — but a project can switch system * variables off, so fall back to two other members of the same set rather than @@ -35,14 +56,30 @@ const VERCEL_ENV_SETTINGS = * build-only, and present under `netlify dev`, so relying on it works locally * and silently fails in production. Functions get `SITE_ID`, `SITE_NAME` and * `URL`, and nothing else. + * + * Local is what is left once no platform claims the deployment: a dev server is + * always local, and a production build reached over loopback is someone running + * `next start` or the Docker compose stack on their own machine. `requestHost` + * is the request's `Host` header, which is the only thing that separates that + * case from a self-hosted server on a real domain — pass it when there is one. */ -export function detectHost(): Host { +export function detectHost(requestHost?: string | null): Host { if (process.env.VERCEL || process.env.VERCEL_URL || process.env.VERCEL_PROJECT_ID) return 'vercel' if (process.env.SITE_ID || process.env.SITE_NAME) return 'netlify' + if (process.env.NODE_ENV !== 'production') return 'local' + if (requestHost) { + const hostname = hostnameOf(requestHost) + if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost')) return 'local' + } return 'unknown' } -/** Where to edit environment variables on this host, or null if we cannot say. */ +/** + * Where to edit environment variables on this host, or null if we cannot say. + * + * Local has no such place on purpose: the variables go in a file the user + * already has open, so the success screen sends them there instead of to a link. + */ export function hostEnvUrl(host: Host): string | null { if (host === 'vercel') return VERCEL_ENV_SETTINGS // Netlify's path is keyed on the site name alone — no team segment to guess — diff --git a/src/app/(setup)/lib/status.ts b/src/app/(setup)/lib/status.ts index 79b39de..e5a0030 100644 --- a/src/app/(setup)/lib/status.ts +++ b/src/app/(setup)/lib/status.ts @@ -96,6 +96,9 @@ export async function getSetupStatus(): Promise { appId = only?.id == null ? null : String(only.id) appName = only?.name == null ? null : String(only.name) + // Deliberately no ids in the text: this page is reachable by anyone who + // can reach the site, so it reports what is wrong without ever printing + // a value the reader could not already have. checks.push({ id: 'app', label: 'Your site', @@ -105,9 +108,7 @@ export async function getSetupStatus(): Promise { ? 'No site found in the database. Re-run setup to create one.' : !envAppKey ? 'CHAIBUILDER_APP_KEY is not set, so there is no way to tell which site this deployment serves.' - : only - ? `CHAIBUILDER_APP_KEY does not match the site in this database. Set it to ${String(only.id)}.` - : 'CHAIBUILDER_APP_KEY does not match any site in this database. Check that you copied the value setup gave you.', + : 'CHAIBUILDER_APP_KEY does not match a site in this database. Check that you copied the whole value setup gave you, or re-run setup to create a new site.', }) } diff --git a/src/app/(setup)/setup/SuccessScreen.tsx b/src/app/(setup)/setup/SuccessScreen.tsx index c5740b8..8977b1a 100644 --- a/src/app/(setup)/setup/SuccessScreen.tsx +++ b/src/app/(setup)/setup/SuccessScreen.tsx @@ -9,7 +9,7 @@ import { CopyButton } from './CopyButton' import { NewTabLink } from './NewTabLink' /** Which host this deployment is running on, detected server-side. */ -export type Host = 'vercel' | 'netlify' | 'unknown' +export type Host = 'vercel' | 'netlify' | 'local' | 'unknown' const DOCS_URL = 'https://www.chaibuilder.com/docs' @@ -52,6 +52,10 @@ export function SuccessScreen({ }) { const [sent, setSent] = useState(false) + // Running on the user's own machine there is no dashboard and no deploy: the + // same variables go into the `.env` file next to the code, and the dev server + // picks them up on restart. + const isLocal = host === 'local' const siteUrl = typeof window === 'undefined' ? '' : window.location.origin const mediaAdded = hasMedia(extras) && !envMedia const aiAdded = Boolean(extras.aiKey.trim()) && !envAi @@ -118,7 +122,9 @@ export function SuccessScreen({

Your site is ready — one last step

- Add these environment variables to your host and redeploy once — that is the last step. + {isLocal + ? 'Add these environment variables to your .env file and restart the dev server — that is the last step.' + : 'Add these environment variables to your host and redeploy once — that is the last step.'}

@@ -126,7 +132,7 @@ export function SuccessScreen({

1. Copy your environment variables

{useEnvDatabase - ? 'DATABASE_URL is already set on this deployment, so it is not repeated here. ' + ? `DATABASE_URL is already set ${isLocal ? 'in your environment' : 'on this deployment'}, so it is not repeated here. ` : ''} This is the only time the password-like values are shown.

@@ -158,8 +164,28 @@ export function SuccessScreen({
-

2. Paste them in and redeploy — once

- {host === 'netlify' ? ( +

+ {isLocal + ? '2. Paste them into .env and restart' + : '2. Paste them in and redeploy — once'} +

+ {isLocal ? ( +
    +
  1. + Open .env in the root of your project — create it if it is not there + yet. It is already in .gitignore, so these values stay off GitHub. +
  2. +
  3. + Paste the whole block in and save. Replace any of these keys that are already in the + file rather than adding a second copy — the last one set wins, and a stale{' '} + CHAIBUILDER_APP_KEY points at a site that is not the one just created. +
  4. +
  5. + Restart the dev server: stop it with Ctrl+C and run{' '} + pnpm dev again. +
  6. +
+ ) : host === 'netlify' ? (
  1. {hostEnvUrl ? ( @@ -212,18 +238,26 @@ export function SuccessScreen({
)}

- When it finishes, sign in at /admin with the email and password you just - chose. + {isLocal ? 'Once it is back up' : 'When it finishes'}, sign in at /admin{' '} + with the email and password you just chose.

- {!mediaAdded && !envMedia && ( - <> - You skipped media storage, so uploaded images will not survive a redeploy —{' '} - the docs cover adding it later.{' '} - - )} + {!mediaAdded && + !envMedia && + (isLocal ? ( + <> + You skipped media storage, so uploads are written to local disk — fine while you are + developing, but add a bucket before you deploy.{' '} + The docs cover it.{' '} + + ) : ( + <> + You skipped media storage, so uploaded images will not survive a redeploy —{' '} + the docs cover adding it later.{' '} + + ))} Setup disables itself once configured: safe to leave, or delete{' '} src/app/(setup) to remove it.

diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 1ca3b7b..ece0533 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -1,3 +1,4 @@ +import { headers } from 'next/headers' import { adminUrl } from '@/utilities/adminRoute' import { isConfigured } from '@/lib/is-configured' import { envDbCredentials, openDb } from '../lib/db' @@ -62,8 +63,12 @@ function hasEnvAi(): boolean { } export default async function SetupPage() { + // The `Host` header is what separates a production build served over loopback + // — someone running `next start` or the compose stack on their own machine — + // from a self-hosted deployment on a real domain. + const host = detectHost((await headers()).get('host')) + if (!isConfigured()) { - const host = detectHost() return (

Optional extras

- Each of these is a matter of adding environment variables to your host and deploying - again — there is no need to run setup a second time.{' '} + {host === 'local' + ? 'Each of these is a matter of adding environment variables to your .env file and restarting — there is no need to run setup a second time. ' + : 'Each of these is a matter of adding environment variables to your host and deploying again — there is no need to run setup a second time. '} The docs walk through each one.

    @@ -146,11 +152,10 @@ export default async function SetupPage() {

    Your site

    - {status.appId && ( -

    - Site ID: {status.appId} -

    - )} + {/* No ids, keys or values here on purpose: once setup has run this page + is reachable by anyone who can reach the site, so it says what is + working without repeating anything worth keeping secret. The values + live in your host's settings and, for the site id, the editor. */}
    {/* Styled links rather than buttons wrapped in anchors, which is invalid HTML and confuses keyboard and assistive-tech users. */} @@ -163,11 +168,27 @@ export default async function SetupPage() {
    -

    - Setup disables itself once configured, so it is safe to leave in place. To remove it, - delete src/app/(setup) and the /setup redirect in{' '} - src/proxy.ts. -

    +
    +

    You can delete this route now

    +

    + Setup has done its job. It refuses to run again while the site is configured, so it is + safe to leave in place — but nothing here is needed any more, and deleting it removes + the page entirely. +

    +
      +
    1. + Delete src/app/(setup). +
    2. +
    3. + Remove the /setup redirect from src/proxy.ts. +
    4. +
    5. + {host === 'local' + ? 'Restart the dev server.' + : 'Commit and deploy — the route is gone from the next build onwards.'} +
    6. +
    +
    ) diff --git a/src/app/(setup)/setup/wizard.tsx b/src/app/(setup)/setup/wizard.tsx index 59d4004..1b604ae 100644 --- a/src/app/(setup)/setup/wizard.tsx +++ b/src/app/(setup)/setup/wizard.tsx @@ -95,6 +95,12 @@ export function SetupWizard({ const running = progress !== 'idle' && progress !== 'done' const dbKey = JSON.stringify([dbUrl.trim(), dbToken.trim()]) + // The same wizard serves a deployment and a checkout on the user's own + // machine. Only the last step really differs — a `.env` file and a restart + // instead of a dashboard and a redeploy — but the promise made up front has to + // match it, so the wording follows the host from the first screen. + const isLocal = host === 'local' + // Working credentials on the deployment mean there is nothing to ask for. The // step stays in the rail, ticked, so it is clear it was handled rather than // silently dropped — but it is not one of the steps the wizard walks through. @@ -232,7 +238,11 @@ export function SetupWizard({

    Set up your ChaiBuilder site

    -

    Three steps, then one redeploy — and your site is live.

    +

    + {isLocal + ? 'Three steps, then one restart — and your site is running.' + : 'Three steps, then one redeploy — and your site is live.'} +

      {ALL_STEPS.map((entry) => { @@ -330,7 +340,8 @@ export function SetupWizard({