diff --git a/.changeset/calm-sites-work.md b/.changeset/calm-sites-work.md new file mode 100644 index 0000000..412712d --- /dev/null +++ b/.changeset/calm-sites-work.md @@ -0,0 +1,7 @@ +--- +'@openai/create-sites': minor +--- + +Add host integrations with `codex` as the default and an optional ChatGPT Work +mode for preview routing and authentication helpers. Ignore local Sites runtime +artifacts and disable the unused Workers inspector in every generated starter. diff --git a/packages/create-sites/README.md b/packages/create-sites/README.md index adbe54f..13ec9b2 100644 --- a/packages/create-sites/README.md +++ b/packages/create-sites/README.md @@ -7,6 +7,9 @@ npm create @openai/sites@latest my-site npm create @openai/sites@latest my-site -- --yes --add-ons d1,r2,shadcn +npm create @openai/sites@latest my-site -- \ + --yes --integration chatgpt-work --add-ons d1,r2,auth,shadcn + pnpm create @openai/sites@latest my-site --yes --add-ons d1,r2,shadcn ``` @@ -20,6 +23,20 @@ Lucide icons, and CSS variables. It includes all 61 entries available through `shadcn add --all`: 60 component source files plus the fileless `form` compatibility entry. +The default integration is `codex`. Use `--integration chatgpt-work` when the +project will instead be authored through ChatGPT Work. The Work integration +configures the generated Vite server for the `terminal.local` preview gateway. +When combined with the `auth` add-on, it generates the email-based +authenticated-user contract provided by ChatGPT Work instead of requiring a +per-Site user ID. + +All starters ignore the project-local `.sites-runtime` directory and disable +the standalone Cloudflare inspector because neither Codex nor ChatGPT Work +preview attaches to the Workers debugger. + +Host integrations do not select add-ons, install packages, or change the +generated dependency graph. Omitting `--integration` selects `codex`. + ```bash npx --yes @openai/create-sites@latest . \ --yes --add-ons d1,r2,shadcn diff --git a/packages/create-sites/src/index.ts b/packages/create-sites/src/index.ts index de4f283..f8ac5c7 100644 --- a/packages/create-sites/src/index.ts +++ b/packages/create-sites/src/index.ts @@ -15,6 +15,7 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import { xSync } from 'tinyexec'; type AddOn = 'd1' | 'r2' | 'auth' | 'shadcn'; +type Integration = 'codex' | 'chatgpt-work'; type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; const addOns: { name: AddOn; description: string }[] = [ @@ -26,6 +27,7 @@ const addOns: { name: AddOn; description: string }[] = [ description: 'Add shadcn/ui with its complete component set.', }, ]; +const integrations: Integration[] = ['codex', 'chatgpt-work']; const packageManagers: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun']; const allowedExistingEntries = new Set(['.git', '.DS_Store']); @@ -38,6 +40,7 @@ type PackageJson = { type CliOptions = { addOns?: AddOn[]; + integration: Integration; yes?: boolean; install: boolean; packageManager?: PackageManager; @@ -45,6 +48,50 @@ type CliOptions = { json?: boolean; }; +function replaceRequired( + contents: string, + search: string, + replacement: string, + description: string, +): string { + if (!contents.includes(search)) { + throw new Error(`Could not apply ${description}.`); + } + return contents.replace(search, replacement); +} + +async function applyChatGPTWorkIntegration( + templates: string, + destination: string, + selected: AddOn[], +): Promise { + const viteConfigPath = join(destination, 'vite.config.ts'); + const viteConfig = replaceRequired( + await readFile(viteConfigPath, 'utf8'), + ` server: isCodexSeatbeltSandbox + ? { watch: { useFsEvents: false, usePolling: true } } + : undefined,`, + ` // ChatGPT Work routes project previews through terminal.local. + server: { + host: '0.0.0.0', + allowedHosts: ['terminal.local'], + ...(isCodexSeatbeltSandbox + ? { watch: { useFsEvents: false, usePolling: true } } + : {}), + },`, + 'the ChatGPT Work Vite server configuration', + ); + await writeFile(viteConfigPath, viteConfig, 'utf8'); + + if (selected.includes('auth')) { + await cp( + join(templates, 'integrations', 'chatgpt-work', 'addons', 'auth'), + destination, + { recursive: true, force: true }, + ); + } +} + function parseAddOns(value: string | undefined): AddOn[] { if (!value?.trim()) return []; const requested = value.split(',').map((name) => name.trim()); @@ -212,6 +259,10 @@ async function createSite( }); } + if (options.integration === 'chatgpt-work') { + await applyChatGPTWorkIntegration(templates, destination, selected); + } + await Promise.all([ writeJson(join(destination, 'package.json'), manifest), writeJson(join(destination, '.openai', 'hosting.json'), hosting), @@ -232,6 +283,7 @@ async function createSite( process.stdout.write( `Add-ons: ${selected.length ? selected.join(', ') : 'none'}.\n`, ); + process.stdout.write(`Integration: ${options.integration}.\n`); if (!options.install) { process.stdout.write('\nNext steps:\n'); @@ -258,6 +310,11 @@ async function main(): Promise { .version(manifest.version, '-v, --version', 'Show the package version') .argument('[directory]', 'Project directory') .option('--add-ons ', 'Comma-separated add-ons', parseAddOns) + .addOption( + new Option('--integration ', 'Host integration') + .choices(integrations) + .default('codex'), + ) .option('-y, --yes', 'Use defaults without prompting') .option('--install', 'Install project dependencies', false) .option('--no-install', 'Generate without installing dependencies') diff --git a/packages/create-sites/templates/integrations/chatgpt-work/addons/auth/app/chatgpt-auth.ts b/packages/create-sites/templates/integrations/chatgpt-work/addons/auth/app/chatgpt-auth.ts new file mode 100644 index 0000000..5623207 --- /dev/null +++ b/packages/create-sites/templates/integrations/chatgpt-work/addons/auth/app/chatgpt-auth.ts @@ -0,0 +1,88 @@ +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +export type ChatGPTUser = { + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_EMAIL_HEADER = 'oai-authenticated-user-email'; +const USER_FULL_NAME_HEADER = 'oai-authenticated-user-full-name'; +const USER_FULL_NAME_ENCODING_HEADER = + 'oai-authenticated-user-full-name-encoding'; +const PERCENT_ENCODED_UTF8 = 'percent-encoded-utf-8'; +const SIGN_IN_PATH = '/signin-with-chatgpt'; +const SIGN_OUT_PATH = '/signout-with-chatgpt'; +const CALLBACK_PATH = '/callback'; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + // ChatGPT Work guarantees the authenticated email header but does not + // require the per-Site user ID exposed by the portable Sites integration. + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = '/'): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith('/') || value.startsWith('//')) return '/'; + + let url: URL; + try { + url = new URL(value, 'https://app.local'); + } catch { + return '/'; + } + if (url.origin !== 'https://app.local') return '/'; + if (isReservedAuthPath(url.pathname)) return '/'; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/packages/create-sites/templates/vinext/_gitignore b/packages/create-sites/templates/vinext/_gitignore index ef6f899..ad4500e 100644 --- a/packages/create-sites/templates/vinext/_gitignore +++ b/packages/create-sites/templates/vinext/_gitignore @@ -34,5 +34,6 @@ yarn-error.log* next-env.d.ts /dist/ /.wrangler/ +/.sites-runtime/ /outputs/ /work/ diff --git a/packages/create-sites/templates/vinext/vite.config.ts b/packages/create-sites/templates/vinext/vite.config.ts index b018fcd..65192aa 100644 --- a/packages/create-sites/templates/vinext/vite.config.ts +++ b/packages/create-sites/templates/vinext/vite.config.ts @@ -53,6 +53,8 @@ export default defineConfig(async () => { vinext(), sites(), cloudflare({ + // Sites preview workflows do not attach to the Workers debugger. + inspectorPort: false, viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] }, config: localBindingConfig, }), diff --git a/packages/create-sites/test/create-sites.test.ts b/packages/create-sites/test/create-sites.test.ts index ccb0c96..83fb0c0 100644 --- a/packages/create-sites/test/create-sites.test.ts +++ b/packages/create-sites/test/create-sites.test.ts @@ -453,6 +453,94 @@ describe('create-sites', () => { } }); + test('applies the ChatGPT Work integration without changing the dependency graph', async () => { + const codexWorkspace = await createWorkspace(); + const workWorkspace = await createWorkspace(); + const addOns = 'd1,r2,auth,shadcn'; + + const codex = runCli(codexWorkspace, [ + 'example-site', + '--yes', + '--add-ons', + addOns, + ]); + const work = runCli(workWorkspace, [ + 'example-site', + '--yes', + '--integration', + 'chatgpt-work', + '--add-ons', + addOns, + ]); + + expect(codex.status, codex.stderr).toBe(0); + expect(work.status, work.stderr).toBe(0); + expect(codex.stdout).toContain('Integration: codex.'); + expect(work.stdout).toContain('Integration: chatgpt-work.'); + + const codexProject = join(codexWorkspace, 'example-site'); + const workProject = join(workWorkspace, 'example-site'); + expect(await readPackage(workProject)).toEqual( + await readPackage(codexProject), + ); + + const codexGitignore = await readFile( + join(codexProject, '.gitignore'), + 'utf8', + ); + const workGitignore = await readFile( + join(workProject, '.gitignore'), + 'utf8', + ); + expect(workGitignore).toBe(codexGitignore); + expect(codexGitignore).toContain('/.sites-runtime/'); + expect(codexGitignore).toContain('/.vinext/'); + + const codexViteConfig = await readFile( + join(codexProject, 'vite.config.ts'), + 'utf8', + ); + const workViteConfig = await readFile( + join(workProject, 'vite.config.ts'), + 'utf8', + ); + expect(codexViteConfig).not.toContain("host: '0.0.0.0'"); + expect(codexViteConfig).not.toContain("allowedHosts: ['terminal.local']"); + expect(codexViteConfig).toContain('inspectorPort: false'); + expect(workViteConfig).toContain("host: '0.0.0.0'"); + expect(workViteConfig).toContain("allowedHosts: ['terminal.local']"); + expect(workViteConfig).toContain('inspectorPort: false'); + + const codexAuth = await readFile( + join(codexProject, 'app', 'chatgpt-auth.ts'), + 'utf8', + ); + const workAuth = await readFile( + join(workProject, 'app', 'chatgpt-auth.ts'), + 'utf8', + ); + expect(codexAuth).toContain('oai-authenticated-user-id'); + expect(codexAuth).toContain('userId: string'); + expect(workAuth).not.toContain('oai-authenticated-user-id'); + expect(workAuth).not.toContain('userId: string'); + expect(workAuth).toContain('oai-authenticated-user-email'); + }); + + test('does not select auth through the ChatGPT Work integration', async () => { + const workspace = await createWorkspace(); + const result = runCli(workspace, [ + 'example-site', + '--yes', + '--integration', + 'chatgpt-work', + ]); + + expect(result.status, result.stderr).toBe(0); + await expect( + access(join(workspace, 'example-site', 'app', 'chatgpt-auth.ts')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + test.each(['npm', 'pnpm', 'yarn', 'bun'])( 'supports selecting %s explicitly', async (manager) => { @@ -518,6 +606,10 @@ describe('create-sites', () => { expect(result.stdout).toContain('Usage: create-sites'); expect(result.stdout).toContain('--add-ons'); expect(result.stdout).toContain('Comma-separated add-ons'); + expect(result.stdout).toContain('--integration'); + expect(result.stdout).toContain('Host integration'); + expect(result.stdout).toContain('codex'); + expect(result.stdout).toContain('chatgpt-work'); expect(result.stdout).not.toContain('d1,r2,auth'); expect(result.stdout).toContain('--yes'); expect(result.stdout).toContain('--package-manager'); @@ -571,6 +663,10 @@ describe('create-sites', () => { name: 'an unsupported package manager', args: ['example-site', '--yes', '--package-manager', 'unsupported'], }, + { + name: 'an unsupported host integration', + args: ['example-site', '--yes', '--integration', 'unsupported'], + }, { name: 'JSON output without an add-on list', args: ['--json'],