diff --git a/packages/cli/src/commands/create/generate-hosting-config.ts b/packages/cli/src/commands/create/generate-hosting-config.ts new file mode 100644 index 000000000..16f1dd594 --- /dev/null +++ b/packages/cli/src/commands/create/generate-hosting-config.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as prompts from '@clack/prompts'; +import chalk from 'chalk'; +import { warnLabel } from 'src/utils/messages.js'; +import { runTask } from 'src/utils/tasks.js'; +import cloudflareConfigRaw from './hosting-config/_headers.txt?raw'; +import netlifyConfigRaw from './hosting-config/netlify_toml.txt?raw'; +import vercelConfigRaw from './hosting-config/vercel.json?raw'; +import { DEFAULT_VALUES, readFlag, type CreateOptions } from './options.js'; + +export async function generateHostingConfig(dest: string, flags: CreateOptions) { + let provider: string | false | symbol = readFlag(flags, 'provider'); + + if (provider === undefined) { + provider = await prompts.select({ + message: 'Select hosting providers for automatic configuration:', + options: [ + { value: 'Vercel', label: 'Vercel' }, + { value: 'Netlify', label: 'Netlify' }, + { value: 'Cloudflare', label: 'Cloudflare' }, + { value: 'skip', label: 'Skip hosting configuration' }, + ], + initialValue: DEFAULT_VALUES.provider, + }); + } + + if (typeof provider !== 'string') { + provider = 'skip'; + } + + if (!provider || provider === 'skip') { + prompts.log.message( + `${chalk.blue('hosting provider config [skip]')} You can configure hosting provider settings manually later.`, + ); + + return provider; + } + + prompts.log.info(`${chalk.blue('Hosting Configuration')} Setting up configuration for ${provider}`); + + const resolvedDest = path.resolve(dest); + + if (!fs.existsSync(resolvedDest)) { + fs.mkdirSync(resolvedDest, { recursive: true }); + } + + let config: string | undefined; + let filename: string | undefined; + + switch (provider.toLowerCase()) { + case 'vercel': { + config = typeof vercelConfigRaw === 'string' ? vercelConfigRaw : JSON.stringify(vercelConfigRaw, null, 2); + filename = 'vercel.json'; + break; + } + case 'netlify': { + config = netlifyConfigRaw; + filename = 'netlify.toml'; + break; + } + case 'cloudflare': { + config = cloudflareConfigRaw; + filename = '_headers'; + break; + } + } + + if (config && filename) { + await runTask({ + title: `Create hosting files for ${provider}`, + dryRun: flags.dryRun, + dryRunMessage: `${warnLabel('DRY RUN')} Skipped hosting provider config creation`, + task: async () => { + const filepath = path.join(resolvedDest, filename); + fs.writeFileSync(filepath, config); + + return `Added ${filepath}`; + }, + }); + } + + return provider; +} diff --git a/packages/cli/src/commands/create/hosting-config/_headers.txt b/packages/cli/src/commands/create/hosting-config/_headers.txt new file mode 100644 index 000000000..a2395ae61 --- /dev/null +++ b/packages/cli/src/commands/create/hosting-config/_headers.txt @@ -0,0 +1,3 @@ +/* + Cross-Origin-Embedder-Policy: require-corp + Cross-Origin-Opener-Policy: same-origin diff --git a/packages/cli/src/commands/create/hosting-config/netlify_toml.txt b/packages/cli/src/commands/create/hosting-config/netlify_toml.txt new file mode 100644 index 000000000..fa8126793 --- /dev/null +++ b/packages/cli/src/commands/create/hosting-config/netlify_toml.txt @@ -0,0 +1,5 @@ +[[headers]] + for = "/*" + [headers.values] + Cross-Origin-Embedder-Policy = "require-corp" + Cross-Origin-Opener-Policy = "same-origin" diff --git a/packages/cli/src/commands/create/hosting-config/vercel.json b/packages/cli/src/commands/create/hosting-config/vercel.json new file mode 100644 index 000000000..2068104d4 --- /dev/null +++ b/packages/cli/src/commands/create/hosting-config/vercel.json @@ -0,0 +1,17 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-crop" + }, + { + "key": "Cross-Origin-Embedder-Policy", + "value": "same-origin" + } + ] + } + ] +} diff --git a/packages/cli/src/commands/create/index.ts b/packages/cli/src/commands/create/index.ts index 0ac059090..cd7fefbc1 100644 --- a/packages/cli/src/commands/create/index.ts +++ b/packages/cli/src/commands/create/index.ts @@ -10,6 +10,7 @@ import { generateProjectName } from '../../utils/project.js'; import { assertNotCanceled } from '../../utils/tasks.js'; import { updateWorkspaceVersions } from '../../utils/workspace-version.js'; import { setupEnterpriseConfig } from './enterprise.js'; +import { generateHostingConfig } from './generate-hosting-config.js'; import { initGitRepo } from './git.js'; import { installAndStart } from './install-start.js'; import { DEFAULT_VALUES, type CreateOptions } from './options.js'; @@ -29,6 +30,10 @@ export async function createTutorial(flags: yargs.Arguments) { ['--install, --no-install', `Install dependencies (default ${chalk.yellow(DEFAULT_VALUES.install)})`], ['--start, --no-start', `Start project (default ${chalk.yellow(DEFAULT_VALUES.start)})`], ['--git, --no-git', `Initialize a local git repository (default ${chalk.yellow(DEFAULT_VALUES.git)})`], + [ + '--provider , --no-provider', + `Select a hosting provider (default ${chalk.yellow(DEFAULT_VALUES.provider)})`, + ], ['--dry-run', `Walk through steps without executing (default ${chalk.yellow(DEFAULT_VALUES.dryRun)})`], [ '--package-manager , -p ', @@ -143,7 +148,9 @@ async function _createTutorial(flags: CreateOptions): Promise { await copyTemplate(resolvedDest, flags); - updatePackageJson(resolvedDest, tutorialName, flags); + const provider = await generateHostingConfig(resolvedDest, flags); + + updatePackageJson(resolvedDest, tutorialName, flags, provider); const selectedPackageManager = await selectPackageManager(resolvedDest, flags); @@ -248,7 +255,7 @@ function printNextSteps(dest: string, packageManager: PackageManager, dependenci } } -function updatePackageJson(dest: string, projectName: string, flags: CreateOptions) { +function updatePackageJson(dest: string, projectName: string, flags: CreateOptions, provider: string) { if (flags.dryRun) { return; } @@ -261,7 +268,12 @@ function updatePackageJson(dest: string, projectName: string, flags: CreateOptio updateWorkspaceVersions(pkgJson.dependencies, TUTORIALKIT_VERSION); updateWorkspaceVersions(pkgJson.devDependencies, TUTORIALKIT_VERSION); - fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, undefined, 2)); + if (provider.toLowerCase() === 'cloudflare') { + pkgJson.scripts = pkgJson.scripts || {}; + pkgJson.scripts.postbuild = 'cp _headers ./dist/'; + } + + fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, null, 2)); try { const pkgLockPath = path.resolve(dest, 'package-lock.json'); @@ -274,7 +286,7 @@ function updatePackageJson(dest: string, projectName: string, flags: CreateOptio defaultPackage.name = projectName; } - fs.writeFileSync(pkgLockPath, JSON.stringify(pkgLockJson, undefined, 2)); + fs.writeFileSync(pkgLockPath, JSON.stringify(pkgLockJson, null, 2)); } catch { // ignore any errors } diff --git a/packages/cli/src/commands/create/options.ts b/packages/cli/src/commands/create/options.ts index ae19d4ebf..c5be2f758 100644 --- a/packages/cli/src/commands/create/options.ts +++ b/packages/cli/src/commands/create/options.ts @@ -12,6 +12,7 @@ export interface CreateOptions { defaults?: boolean; packageManager?: string; force?: boolean; + provider?: string; } const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -25,6 +26,7 @@ export const DEFAULT_VALUES = { dryRun: false, force: false, packageManager: 'npm', + provider: 'skip', }; type Flags = Omit; diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts new file mode 100644 index 000000000..88d404d0a --- /dev/null +++ b/packages/cli/src/types.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/packages/cli/tests/create-tutorial.test.ts b/packages/cli/tests/create-tutorial.test.ts index 9b4ef2f5f..5ef22bf35 100644 --- a/packages/cli/tests/create-tutorial.test.ts +++ b/packages/cli/tests/create-tutorial.test.ts @@ -30,7 +30,7 @@ test('cannot create project without installing but with starting', async (contex const name = context.task.id; await expect( - execa('node', [cli, 'create', name, '--no-install', '--start'], { + execa('node', [cli, 'create', name, '--no-install', '--no-provider', '--start'], { cwd: tmpDir, }), ).rejects.toThrow('Cannot start project without installing dependencies.'); @@ -40,7 +40,7 @@ test('create a project', async (context) => { const name = context.task.id; const dest = path.join(tmpDir, name); - await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults'], { + await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--no-provider', '--defaults'], { cwd: tmpDir, }); @@ -49,11 +49,54 @@ test('create a project', async (context) => { expect(projectFiles.map(normaliseSlash).sort()).toMatchSnapshot(); }); +test('create a project with Netlify as provider', async (context) => { + const name = context.task.id; + const dest = path.join(tmpDir, name); + + await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'netlify'], { + cwd: tmpDir, + }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + expect(projectFiles).toContain('netlify.toml'); +}); + +test('create a project with Cloudflare as provider', async (context) => { + const name = context.task.id; + const dest = path.join(tmpDir, name); + + await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'cloudflare'], { + cwd: tmpDir, + }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + expect(projectFiles).toContain('_headers'); + + const packageJson = await fs.readFile(`${dest}/package.json`, 'utf8'); + const json = JSON.parse(packageJson); + + expect(json).toHaveProperty('scripts'); + expect(json.scripts).toHaveProperty('postbuild'); + expect(json.scripts.postbuild).toBe('cp _headers ./dist/'); +}); + +test('create a project with Vercel as provider', async (context) => { + const name = context.task.id; + const dest = path.join(tmpDir, name); + + await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'vercel'], { + cwd: tmpDir, + }); + + const projectFiles = await fs.readdir(dest, { recursive: true }); + expect(projectFiles).toContain('vercel.json'); +}); + test('create and build a project', async (context) => { const name = context.task.id; const dest = path.join(tmpDir, name); - await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], { + await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], { cwd: tmpDir, }); @@ -89,7 +132,7 @@ test('create and eject a project', async (context) => { const name = context.task.id; const dest = path.join(tmpDir, name); - await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], { + await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], { cwd: tmpDir, }); @@ -117,7 +160,7 @@ test('create, eject and build a project', async (context) => { const name = context.task.id; const dest = path.join(tmpDir, name); - await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], { + await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], { cwd: tmpDir, });