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
108 changes: 29 additions & 79 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,98 +1,48 @@
# vinext-starter
# RED website

A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
Marketing site for [RED](https://github.com/codersauce/red), the modal terminal
editor for the agent era. Built on [vinext](https://github.com/cloudflare/vinext)
(Next-compatible app router on Vite) and deployed as a Cloudflare Worker.

## Prerequisites

- Node.js `>=22.13.0`

## Quick Start
## Development

```bash
npm install
npm run dev
npm run build
npm run dev # local dev server
npm run build # production build (dist/)
npm test # installer drift check + build + SSR assertions
npm run lint
```

This starter does not use `wrangler.jsonc`.
## Layout

## Included Shape
- `app/` — the site (single landing page, layout, global styles, 404)
- `public/` — static assets: editor captures, OG image, favicon, and the
installers (`install.sh`, `install.ps1`, `installers.json`)
- `worker/index.ts` — Cloudflare Worker entry (image optimization + app router)
- `scripts/sync-installers.mjs` — syncs installers from `codersauce/red`
- `tests/rendered-html.test.mjs` — asserts on real SSR output of the built worker

- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Installer sync

## Workspace Auth Headers
The install scripts served at `/install.sh` and `/install.ps1` are vendored
from the [`codersauce/red`](https://github.com/codersauce/red) repository and
pinned to a commit in `public/installers.json`:

OpenAI workspace sites can read the current user's email from
`oai-authenticated-user-email`.

SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.

Treat the full name as optional and fall back to email when it is absent:

```tsx
import { headers } from "next/headers";

export default async function Home() {
const requestHeaders = await headers();
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;

const displayName = fullName ?? email;
// ...
}
```bash
npm run sync:installers -- --ref <red commit> # update to a new commit
npm run check:installers # CI drift guard (part of npm test)
```

## Optional Dispatch-Owned ChatGPT Sign-In

Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:

- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.

Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.

SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.

Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.

## Useful Commands

- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
The sync also regenerates `app/installers.generated.ts`, which feeds the
release version shown in the hero.

## Learn More
## Deployment

- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
Hosting is driven by `.openai/hosting.json`; `npm run build` packages the site
metadata into `dist/.openai/` via `build/sites-vite-plugin.ts`. There is no
`wrangler.jsonc` — the deploy config is generated into `dist/server/`.
86 changes: 0 additions & 86 deletions app/chatgpt-auth.ts

This file was deleted.

50 changes: 50 additions & 0 deletions app/components/InstallPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";

import { useEffect, useState } from "react";
import { installMethods, type InstallMethod } from "../install-methods";

export default function InstallPicker({ wide = false }: { wide?: boolean }) {
const [installMethod, setInstallMethod] = useState<InstallMethod>("homebrew");
const [copied, setCopied] = useState(false);

useEffect(() => {
const platform = (
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
navigator.platform ??
""
).toLowerCase();
const detected = platform.includes("win") ? "windows" : platform.includes("linux") ? "unix" : null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- SSR must render the Homebrew default; the visitor's OS is only knowable after hydration
if (detected) setInstallMethod(detected);
}, []);

const active = installMethods[installMethod];

async function copyInstall() {
try {
await navigator.clipboard.writeText(active.command);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}

return <>
<div className="install-selector" role="tablist" aria-label="Installation method">
{(Object.keys(installMethods) as InstallMethod[]).map((method) => <button
className={installMethod === method ? "active" : ""}
type="button"
role="tab"
aria-selected={installMethod === method}
onClick={() => {
setInstallMethod(method);
setCopied(false);
}}
key={method}
>{installMethods[method].label}</button>)}
</div>
<button className={wide ? "install wide" : "install"} onClick={copyInstall} aria-label={active.aria}><span className="prompt">$</span><code>{active.command}</code><span className="copy">{copied ? "copied" : "copy"}</span></button>
<span className="sr-only" role="status">{copied ? "Install command copied to clipboard" : ""}</span>
</>;
}
95 changes: 95 additions & 0 deletions app/components/PreviewTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useState, type KeyboardEvent } from "react";
import Image from "next/image";

const previewOrder = ["edit", "find", "commands", "agent", "git", "splash"] as const;
type Preview = (typeof previewOrder)[number];

const previews: Record<Preview, { label: string; title: string; image: string; alt: string; note: string }> = {
edit: {
label: "Edit",
title: "Rendering pipeline",
image: "/ghostty-code.jpg",
alt: "Red editing its Rust rendering pipeline with the project tree open",
note: "src/editor/rendering.rs · Rust · tree-sitter",
},
find: {
label: "Files",
title: "Find source files",
image: "/ghostty-picker-demo.jpg",
alt: "Red file picker filtering its own rendering and buffer source files",
note: "Ctrl-p · fuzzy files · live preview",
},
commands: {
label: "Commands",
title: "Discover Git actions",
image: "/ghostty-commands-demo.jpg",
alt: "Red command palette showing Git hunk navigation, stage, reset, and unstage actions",
note: "Space ? · 74 commands · shortcuts included",
},
agent: {
label: "Agent",
title: "Ask with context",
image: "/ghostty-agent.jpg",
alt: "Red agent prompt asking about tree-sitter injections over the editor highlighter source",
note: "Space A · source-aware prompt",
},
git: {
label: "Git review",
title: "Review a real diff",
image: "/ghostty-git-workspace.jpg",
alt: "Red Git workspace reviewing an unstaged change to its rendering pipeline",
note: "Space G · unstaged rendering diff",
},
splash: {
label: "Welcome",
title: "Start from blank",
image: "/ghostty-splash.jpg",
alt: "Red blank-state splash screen showing its logo, startup shortcuts, and agent safety message",
note: "empty buffer · branded splash",
},
};

export default function PreviewTabs() {
const [preview, setPreview] = useState<Preview>("commands");

function handlePreviewKey(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % previewOrder.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + previewOrder.length) % previewOrder.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = previewOrder.length - 1;
else return;

event.preventDefault();
setPreview(previewOrder[next]);
document.getElementById(`preview-tab-${previewOrder[next]}`)?.focus();
}

const active = previews[preview];

return <section className="preview-wrap shell" id="editor" aria-label="Real editor previews">
<header className="preview-head">
<div className="preview-title"><span className="preview-dot" /><span>{active.title}</span></div>
<div className="preview-tabs" role="tablist" aria-label="Editor previews">
{previewOrder.map((key, index) => <button
className={preview === key ? "active" : ""}
id={`preview-tab-${key}`}
role="tab"
aria-selected={preview === key}
aria-controls="preview-panel"
tabIndex={preview === key ? 0 : -1}
onClick={() => setPreview(key)}
onKeyDown={(event) => handlePreviewKey(event, index)}
key={key}
>{previews[key].label}</button>)}
</div>
<span className="preview-file">real editor · Ghostty</span>
</header>
<figure className="live-shot" id="preview-panel" role="tabpanel" aria-labelledby={`preview-tab-${preview}`} tabIndex={0}>
<Image src={active.image} width={1208} height={704} alt={active.alt} unoptimized />
<figcaption><span>Red source capture</span><span>{active.note}</span></figcaption>
</figure>
</section>;
}
Loading
Loading