Skip to content

Commit efd7ee7

Browse files
feat(cli): project creation to prompt hosting provider settings (#440)
1 parent b2384ff commit efd7ee7

File tree

8 files changed

+179
-9
lines changed

8 files changed

+179
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import * as prompts from '@clack/prompts';
4+
import chalk from 'chalk';
5+
import { warnLabel } from 'src/utils/messages.js';
6+
import { runTask } from 'src/utils/tasks.js';
7+
import cloudflareConfigRaw from './hosting-config/_headers.txt?raw';
8+
import netlifyConfigRaw from './hosting-config/netlify_toml.txt?raw';
9+
import vercelConfigRaw from './hosting-config/vercel.json?raw';
10+
import { DEFAULT_VALUES, readFlag, type CreateOptions } from './options.js';
11+
12+
export async function generateHostingConfig(dest: string, flags: CreateOptions) {
13+
let provider: string | false | symbol = readFlag(flags, 'provider');
14+
15+
if (provider === undefined) {
16+
provider = await prompts.select({
17+
message: 'Select hosting providers for automatic configuration:',
18+
options: [
19+
{ value: 'Vercel', label: 'Vercel' },
20+
{ value: 'Netlify', label: 'Netlify' },
21+
{ value: 'Cloudflare', label: 'Cloudflare' },
22+
{ value: 'skip', label: 'Skip hosting configuration' },
23+
],
24+
initialValue: DEFAULT_VALUES.provider,
25+
});
26+
}
27+
28+
if (typeof provider !== 'string') {
29+
provider = 'skip';
30+
}
31+
32+
if (!provider || provider === 'skip') {
33+
prompts.log.message(
34+
`${chalk.blue('hosting provider config [skip]')} You can configure hosting provider settings manually later.`,
35+
);
36+
37+
return provider;
38+
}
39+
40+
prompts.log.info(`${chalk.blue('Hosting Configuration')} Setting up configuration for ${provider}`);
41+
42+
const resolvedDest = path.resolve(dest);
43+
44+
if (!fs.existsSync(resolvedDest)) {
45+
fs.mkdirSync(resolvedDest, { recursive: true });
46+
}
47+
48+
let config: string | undefined;
49+
let filename: string | undefined;
50+
51+
switch (provider.toLowerCase()) {
52+
case 'vercel': {
53+
config = typeof vercelConfigRaw === 'string' ? vercelConfigRaw : JSON.stringify(vercelConfigRaw, null, 2);
54+
filename = 'vercel.json';
55+
break;
56+
}
57+
case 'netlify': {
58+
config = netlifyConfigRaw;
59+
filename = 'netlify.toml';
60+
break;
61+
}
62+
case 'cloudflare': {
63+
config = cloudflareConfigRaw;
64+
filename = '_headers';
65+
break;
66+
}
67+
}
68+
69+
if (config && filename) {
70+
await runTask({
71+
title: `Create hosting files for ${provider}`,
72+
dryRun: flags.dryRun,
73+
dryRunMessage: `${warnLabel('DRY RUN')} Skipped hosting provider config creation`,
74+
task: async () => {
75+
const filepath = path.join(resolvedDest, filename);
76+
fs.writeFileSync(filepath, config);
77+
78+
return `Added ${filepath}`;
79+
},
80+
});
81+
}
82+
83+
return provider;
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/*
2+
Cross-Origin-Embedder-Policy: require-corp
3+
Cross-Origin-Opener-Policy: same-origin
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[[headers]]
2+
for = "/*"
3+
[headers.values]
4+
Cross-Origin-Embedder-Policy = "require-corp"
5+
Cross-Origin-Opener-Policy = "same-origin"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"headers": [
3+
{
4+
"source": "/(.*)",
5+
"headers": [
6+
{
7+
"key": "Cross-Origin-Embedder-Policy",
8+
"value": "require-crop"
9+
},
10+
{
11+
"key": "Cross-Origin-Embedder-Policy",
12+
"value": "same-origin"
13+
}
14+
]
15+
}
16+
]
17+
}

Diff for: packages/cli/src/commands/create/index.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { generateProjectName } from '../../utils/project.js';
1010
import { assertNotCanceled } from '../../utils/tasks.js';
1111
import { updateWorkspaceVersions } from '../../utils/workspace-version.js';
1212
import { setupEnterpriseConfig } from './enterprise.js';
13+
import { generateHostingConfig } from './generate-hosting-config.js';
1314
import { initGitRepo } from './git.js';
1415
import { installAndStart } from './install-start.js';
1516
import { DEFAULT_VALUES, type CreateOptions } from './options.js';
@@ -29,6 +30,10 @@ export async function createTutorial(flags: yargs.Arguments) {
2930
['--install, --no-install', `Install dependencies (default ${chalk.yellow(DEFAULT_VALUES.install)})`],
3031
['--start, --no-start', `Start project (default ${chalk.yellow(DEFAULT_VALUES.start)})`],
3132
['--git, --no-git', `Initialize a local git repository (default ${chalk.yellow(DEFAULT_VALUES.git)})`],
33+
[
34+
'--provider <name>, --no-provider',
35+
`Select a hosting provider (default ${chalk.yellow(DEFAULT_VALUES.provider)})`,
36+
],
3237
['--dry-run', `Walk through steps without executing (default ${chalk.yellow(DEFAULT_VALUES.dryRun)})`],
3338
[
3439
'--package-manager <name>, -p <name>',
@@ -143,7 +148,9 @@ async function _createTutorial(flags: CreateOptions): Promise<undefined> {
143148

144149
await copyTemplate(resolvedDest, flags);
145150

146-
updatePackageJson(resolvedDest, tutorialName, flags);
151+
const provider = await generateHostingConfig(resolvedDest, flags);
152+
153+
updatePackageJson(resolvedDest, tutorialName, flags, provider);
147154

148155
const selectedPackageManager = await selectPackageManager(resolvedDest, flags);
149156

@@ -248,7 +255,7 @@ function printNextSteps(dest: string, packageManager: PackageManager, dependenci
248255
}
249256
}
250257

251-
function updatePackageJson(dest: string, projectName: string, flags: CreateOptions) {
258+
function updatePackageJson(dest: string, projectName: string, flags: CreateOptions, provider: string) {
252259
if (flags.dryRun) {
253260
return;
254261
}
@@ -261,7 +268,12 @@ function updatePackageJson(dest: string, projectName: string, flags: CreateOptio
261268
updateWorkspaceVersions(pkgJson.dependencies, TUTORIALKIT_VERSION);
262269
updateWorkspaceVersions(pkgJson.devDependencies, TUTORIALKIT_VERSION);
263270

264-
fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, undefined, 2));
271+
if (provider.toLowerCase() === 'cloudflare') {
272+
pkgJson.scripts = pkgJson.scripts || {};
273+
pkgJson.scripts.postbuild = 'cp _headers ./dist/';
274+
}
275+
276+
fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, null, 2));
265277

266278
try {
267279
const pkgLockPath = path.resolve(dest, 'package-lock.json');
@@ -274,7 +286,7 @@ function updatePackageJson(dest: string, projectName: string, flags: CreateOptio
274286
defaultPackage.name = projectName;
275287
}
276288

277-
fs.writeFileSync(pkgLockPath, JSON.stringify(pkgLockJson, undefined, 2));
289+
fs.writeFileSync(pkgLockPath, JSON.stringify(pkgLockJson, null, 2));
278290
} catch {
279291
// ignore any errors
280292
}

Diff for: packages/cli/src/commands/create/options.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface CreateOptions {
1212
defaults?: boolean;
1313
packageManager?: string;
1414
force?: boolean;
15+
provider?: string;
1516
}
1617

1718
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -25,6 +26,7 @@ export const DEFAULT_VALUES = {
2526
dryRun: false,
2627
force: false,
2728
packageManager: 'npm',
29+
provider: 'skip',
2830
};
2931

3032
type Flags = Omit<CreateOptions, '_'>;

Diff for: packages/cli/src/types.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module '*?raw' {
2+
const content: string;
3+
export default content;
4+
}

Diff for: packages/cli/tests/create-tutorial.test.ts

+48-5
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test('cannot create project without installing but with starting', async (contex
3030
const name = context.task.id;
3131

3232
await expect(
33-
execa('node', [cli, 'create', name, '--no-install', '--start'], {
33+
execa('node', [cli, 'create', name, '--no-install', '--no-provider', '--start'], {
3434
cwd: tmpDir,
3535
}),
3636
).rejects.toThrow('Cannot start project without installing dependencies.');
@@ -40,7 +40,7 @@ test('create a project', async (context) => {
4040
const name = context.task.id;
4141
const dest = path.join(tmpDir, name);
4242

43-
await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults'], {
43+
await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--no-provider', '--defaults'], {
4444
cwd: tmpDir,
4545
});
4646

@@ -49,11 +49,54 @@ test('create a project', async (context) => {
4949
expect(projectFiles.map(normaliseSlash).sort()).toMatchSnapshot();
5050
});
5151

52+
test('create a project with Netlify as provider', async (context) => {
53+
const name = context.task.id;
54+
const dest = path.join(tmpDir, name);
55+
56+
await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'netlify'], {
57+
cwd: tmpDir,
58+
});
59+
60+
const projectFiles = await fs.readdir(dest, { recursive: true });
61+
expect(projectFiles).toContain('netlify.toml');
62+
});
63+
64+
test('create a project with Cloudflare as provider', async (context) => {
65+
const name = context.task.id;
66+
const dest = path.join(tmpDir, name);
67+
68+
await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'cloudflare'], {
69+
cwd: tmpDir,
70+
});
71+
72+
const projectFiles = await fs.readdir(dest, { recursive: true });
73+
expect(projectFiles).toContain('_headers');
74+
75+
const packageJson = await fs.readFile(`${dest}/package.json`, 'utf8');
76+
const json = JSON.parse(packageJson);
77+
78+
expect(json).toHaveProperty('scripts');
79+
expect(json.scripts).toHaveProperty('postbuild');
80+
expect(json.scripts.postbuild).toBe('cp _headers ./dist/');
81+
});
82+
83+
test('create a project with Vercel as provider', async (context) => {
84+
const name = context.task.id;
85+
const dest = path.join(tmpDir, name);
86+
87+
await execa('node', [cli, 'create', name, '--no-install', '--no-git', '--defaults', '--provider', 'vercel'], {
88+
cwd: tmpDir,
89+
});
90+
91+
const projectFiles = await fs.readdir(dest, { recursive: true });
92+
expect(projectFiles).toContain('vercel.json');
93+
});
94+
5295
test('create and build a project', async (context) => {
5396
const name = context.task.id;
5497
const dest = path.join(tmpDir, name);
5598

56-
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], {
99+
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], {
57100
cwd: tmpDir,
58101
});
59102

@@ -89,7 +132,7 @@ test('create and eject a project', async (context) => {
89132
const name = context.task.id;
90133
const dest = path.join(tmpDir, name);
91134

92-
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], {
135+
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], {
93136
cwd: tmpDir,
94137
});
95138

@@ -117,7 +160,7 @@ test('create, eject and build a project', async (context) => {
117160
const name = context.task.id;
118161
const dest = path.join(tmpDir, name);
119162

120-
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--defaults'], {
163+
await execa('node', [cli, 'create', name, '--no-git', '--no-install', '--no-start', '--no-provider', '--defaults'], {
121164
cwd: tmpDir,
122165
});
123166

0 commit comments

Comments
 (0)