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({