Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/app/(setup)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 },
},
}

/**
Expand Down
49 changes: 43 additions & 6 deletions src/app/(setup)/lib/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 —
Expand Down
7 changes: 4 additions & 3 deletions src/app/(setup)/lib/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export async function getSetupStatus(): Promise<SetupStatus> {
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',
Expand All @@ -105,9 +108,7 @@ export async function getSetupStatus(): Promise<SetupStatus> {
? '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.',
})
}

Expand Down
60 changes: 47 additions & 13 deletions src/app/(setup)/setup/SuccessScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -118,15 +122,17 @@ export function SuccessScreen({
<BrandHeader />
<h1>Your site is ready — one last step</h1>
<p className="lede">
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.'}
</p>

<div className="scroll-area">
<div className="card">
<h2>1. Copy your environment variables</h2>
<p className="hint">
{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.
</p>
Expand Down Expand Up @@ -158,8 +164,28 @@ export function SuccessScreen({
</div>

<div className="card">
<h2>2. Paste them in and redeploy — once</h2>
{host === 'netlify' ? (
<h2>
{isLocal
? '2. Paste them into .env and restart'
: '2. Paste them in and redeploy — once'}
</h2>
{isLocal ? (
<ol className="steps">
<li>
Open <code>.env</code> in the root of your project — create it if it is not there
yet. It is already in <code>.gitignore</code>, so these values stay off GitHub.
</li>
<li>
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{' '}
<code>CHAIBUILDER_APP_KEY</code> points at a site that is not the one just created.
</li>
<li>
Restart the dev server: stop it with <code>Ctrl+C</code> and run{' '}
<code>pnpm dev</code> again.
</li>
</ol>
) : host === 'netlify' ? (
<ol className="steps">
<li>
{hostEnvUrl ? (
Expand Down Expand Up @@ -212,18 +238,26 @@ export function SuccessScreen({
</ol>
)}
<p>
When it finishes, sign in at <code>/admin</code> with the email and password you just
chose.
{isLocal ? 'Once it is back up' : 'When it finishes'}, sign in at <code>/admin</code>{' '}
with the email and password you just chose.
</p>
</div>

<p className="hint">
{!mediaAdded && !envMedia && (
<>
You skipped media storage, so uploaded images will not survive a redeploy —{' '}
<NewTabLink href={DOCS_URL}>the docs</NewTabLink> 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.{' '}
<NewTabLink href={DOCS_URL}>The docs</NewTabLink> cover it.{' '}
</>
) : (
<>
You skipped media storage, so uploaded images will not survive a redeploy —{' '}
<NewTabLink href={DOCS_URL}>the docs</NewTabLink> cover adding it later.{' '}
</>
))}
Setup disables itself once configured: safe to leave, or delete{' '}
<code>src/app/(setup)</code> to remove it.
</p>
Expand Down
47 changes: 34 additions & 13 deletions src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<SetupWizard
envDatabase={await probeEnvDatabase()}
Expand Down Expand Up @@ -115,8 +120,9 @@ export default async function SetupPage() {
<div className="card">
<h2>Optional extras</h2>
<p className="hint">
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. '}
<NewTabLink href={DOCS_URL}>The docs</NewTabLink> walk through each one.
</p>
<ul className="steps">
Expand Down Expand Up @@ -146,11 +152,10 @@ export default async function SetupPage() {

<div className="card">
<h2>Your site</h2>
{status.appId && (
<p className="hint">
Site ID: <code>{status.appId}</code>
</p>
)}
{/* 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. */}
<div className="actions">
{/* Styled links rather than buttons wrapped in anchors, which is
invalid HTML and confuses keyboard and assistive-tech users. */}
Expand All @@ -163,11 +168,27 @@ export default async function SetupPage() {
</div>
</div>

<p className="hint">
Setup disables itself once configured, so it is safe to leave in place. To remove it,
delete <code>src/app/(setup)</code> and the <code>/setup</code> redirect in{' '}
<code>src/proxy.ts</code>.
</p>
<div className="card">
<h2>You can delete this route now</h2>
<p className="hint">
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.
</p>
<ol className="steps">
<li>
Delete <code>src/app/(setup)</code>.
</li>
<li>
Remove the <code>/setup</code> redirect from <code>src/proxy.ts</code>.
</li>
<li>
{host === 'local'
? 'Restart the dev server.'
: 'Commit and deploy — the route is gone from the next build onwards.'}
</li>
</ol>
</div>
</div>
</div>
)
Expand Down
42 changes: 32 additions & 10 deletions src/app/(setup)/setup/wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -232,7 +238,11 @@ export function SetupWizard({
<div className="wrap">
<BrandHeader />
<h1>Set up your ChaiBuilder site</h1>
<p className="lede">Three steps, then one redeploy — and your site is live.</p>
<p className="lede">
{isLocal
? 'Three steps, then one restart — and your site is running.'
: 'Three steps, then one redeploy — and your site is live.'}
</p>

<ol className="stepper">
{ALL_STEPS.map((entry) => {
Expand Down Expand Up @@ -330,7 +340,8 @@ export function SetupWizard({
<aside className="cli-aside">
<strong>Prefer the command line?</strong>{' '}
<span className="hint-inline">
This does the same setup locally and writes a <code>.env</code> for you.
This does the same setup{isLocal ? '' : ' locally'} and writes a <code>.env</code>{' '}
for you.
</span>
<div className="cli-command">
<code>{CLI_COMMAND}</code>
Expand All @@ -348,25 +359,36 @@ export function SetupWizard({
{envDatabase.error} Enter them again below.
</div>
)}
<p className="hint">
Your pages and content live in a hosted libSQL database — a free one from{' '}
<NewTabLink href="https://turso.tech">Turso</NewTabLink> works well. Create it, then
copy its{' '}
<code>libsql://</code> address here.
</p>
{isLocal ? (
<p className="hint">
Your pages and content live in a libSQL database. On your own machine a file in
the project is enough — use <code>file:./payload.db</code> and it is created for
you. To point at a hosted database instead, paste its <code>libsql://</code>{' '}
address; a free one from <NewTabLink href="https://turso.tech">Turso</NewTabLink>{' '}
works well.
</p>
) : (
<p className="hint">
Your pages and content live in a hosted libSQL database — a free one from{' '}
<NewTabLink href="https://turso.tech">Turso</NewTabLink> works well. Create it,
then copy its <code>libsql://</code> address here.
</p>
)}

<label htmlFor="dbUrl">Database URL</label>
<input
id="dbUrl"
type="text"
value={dbUrl}
onChange={(e) => setDbUrl(e.target.value)}
placeholder="libsql://your-database.turso.io"
placeholder={isLocal ? 'file:./payload.db' : 'libsql://your-database.turso.io'}
/>

<label htmlFor="dbToken">Database token</label>
<div className="field-hint">
Your provider issues one alongside the URL and will refuse the connection without it.
{isLocal
? 'Only for hosted databases — leave this empty when the URL is a file.'
: 'Your provider issues one alongside the URL and will refuse the connection without it.'}
</div>
<input
id="dbToken"
Expand Down
Loading