Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/calm-sites-work.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/create-sites/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/create-sites/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand All @@ -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']);

Expand All @@ -38,13 +40,58 @@ type PackageJson = {

type CliOptions = {
addOns?: AddOn[];
integration: Integration;
yes?: boolean;
install: boolean;
packageManager?: PackageManager;
listAddOns?: boolean;
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<void> {
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());
Expand Down Expand Up @@ -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),
Expand All @@ -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');
Expand All @@ -258,6 +310,11 @@ async function main(): Promise<void> {
.version(manifest.version, '-v, --version', 'Show the package version')
.argument('[directory]', 'Project directory')
.option('--add-ons <list>', 'Comma-separated add-ons', parseAddOns)
.addOption(
new Option('--integration <name>', '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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatGPTUser | null> {
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<ChatGPTUser> {
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;
}
}
1 change: 1 addition & 0 deletions packages/create-sites/templates/vinext/_gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ yarn-error.log*
next-env.d.ts
/dist/
/.wrangler/
/.sites-runtime/
/outputs/
/work/
2 changes: 2 additions & 0 deletions packages/create-sites/templates/vinext/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
96 changes: 96 additions & 0 deletions packages/create-sites/test/create-sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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'],
Expand Down