Skip to content

feat: add hosting config files #440

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ac47667
feat(scaffolding): add hosting config files for common providers
RonithManikonda Nov 7, 2024
5de42a3
Attempted to fix error with locating config files
RonithManikonda Nov 21, 2024
2c062e9
feat(cli): move hosting configuration generation to dedicated file
RonithManikonda Dec 19, 2024
986ec67
feat(wizard): restrict hosting provider selection to a single option
RonithManikonda Jan 9, 2025
686174d
feat(cli): adjusted the order in which files are generated to avoid e…
RonithManikonda Jan 29, 2025
b4247e1
deleted tutorials that were made during testing
RonithManikonda Feb 6, 2025
14e3408
Merge branch 'main' into ronith/add-hosting-config-files
AriPerkkio Feb 20, 2025
4df1fc6
fix(cli): add 'skip' option and update defaults for hosting config
RonithManikonda Feb 27, 2025
edd717d
chore(prettier): fix formatting errors
RonithManikonda Mar 5, 2025
af74cd9
feat(cli): add provider flag to hosting config prompt
RonithManikonda Mar 12, 2025
facf7d2
chore(prettier): fix formatting errors
RonithManikonda Mar 13, 2025
7c45e6e
fix(cli): ensure --no-provider skips hosting config prompt
RonithManikonda Mar 19, 2025
bbc7ec9
chore(prettier): fix formatting errors
RonithManikonda Mar 19, 2025
13103d9
chore(prettier): fix formatting errors
RonithManikonda Mar 19, 2025
675c5fb
Apply suggestions from code review
RonithManikonda Mar 26, 2025
a1771db
test(cli): add tests for Netlify, Cloudflare, and Vercel providers
RonithManikonda Mar 26, 2025
8de3e52
Merge branch 'ronith/add-hosting-config-files' of https://github.com/…
RonithManikonda Mar 26, 2025
8abfb97
test(cli): updated test for Cloudflare to reflect functionality
RonithManikonda Mar 26, 2025
9dcefcb
Update packages/cli/src/commands/create/index.ts
AriPerkkio Mar 31, 2025
97a237d
fix: handle `--no-provider` value
AriPerkkio Mar 31, 2025
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
84 changes: 84 additions & 0 deletions packages/cli/src/commands/create/generate-hosting-config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/cli/src/commands/create/hosting-config/_headers.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[[headers]]
for = "/*"
[headers.values]
Cross-Origin-Embedder-Policy = "require-corp"
Cross-Origin-Opener-Policy = "same-origin"
17 changes: 17 additions & 0 deletions packages/cli/src/commands/create/hosting-config/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-crop"
},
{
"key": "Cross-Origin-Embedder-Policy",
"value": "same-origin"
}
]
}
]
}
20 changes: 16 additions & 4 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <name>, --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 <name>, -p <name>',
Expand Down Expand Up @@ -143,7 +148,9 @@ async function _createTutorial(flags: CreateOptions): Promise<undefined> {

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);

Expand Down Expand Up @@ -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;
}
Expand All @@ -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');
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/create/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CreateOptions {
defaults?: boolean;
packageManager?: string;
force?: boolean;
provider?: string;
}

const __dirname = path.dirname(fileURLToPath(import.meta.url));
Expand All @@ -25,6 +26,7 @@ export const DEFAULT_VALUES = {
dryRun: false,
force: false,
packageManager: 'npm',
provider: 'skip',
};

type Flags = Omit<CreateOptions, '_'>;
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*?raw' {
const content: string;
export default content;
}
53 changes: 48 additions & 5 deletions packages/cli/tests/create-tutorial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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,
});

Expand All @@ -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,
});

Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
});

Expand Down