rsnext/packages/create-next-app/index.ts
C. Lewis 389c77f205
feat(create-next-app): JS/TS prompt (with appDir support); enhanced testing (#42012)
Resubmitting this PR following this comment from Tim. This work has been done already and we can build off of it to get this in faster. 

> Just to be clear we're planning to rework create-next-app to give you the option to choose between JavaScript or TypeScript so it'll solve this request. For `app` right now it'll stay TypeScript till that is implemented.
> 
> _Originally posted by @timneutkens in https://github.com/vercel/next.js/discussions/41745#discussioncomment-3985833_

---

I added the `--ts, --typescript` flag to `create-next-app` in https://github.com/vercel/next.js/pull/24655, and since then I have seen it used very frequently, including in recent issues (such as https://github.com/vercel/next.js/issues/33314) and most Next.js tutorials on YouTube. I noticed the template logic added in this PR was also used to add the `appDir` configuration as well.

We discussed a while ago adding the following user flow:

- `create-next-app --js, --javascript` creates a JS project
- `create-next-app --ts, --typescript` creates a TS project
- `create-next-app [name]` (no `--js, --ts`) prompts the user to choose either JS or TS, with TS selected by default.

### Review

Adding support for appDir and refactoring the templates brought the pain-of-review up a bit, but it's not so bad when broken into increments.

The original 8-file diff is here:
1f47d9b0bf

And the PR that brought the diff up to 59 files (mostly owed to `app` template dirs and file structure refactors):
bd3ae4afd3 ([PR link](https://github.com/ctjlewis/next.js/pull/3/files))

### Demo

https://user-images.githubusercontent.com/1657236/198586216-4691ff4c-48d4-4c6c-b7c1-705c38dd0194.mov


Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
2022-10-31 05:43:39 +00:00

271 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable import/no-extraneous-dependencies */
import chalk from 'chalk'
import Commander from 'commander'
import path from 'path'
import prompts from 'prompts'
import checkForUpdate from 'update-check'
import { createApp, DownloadError } from './create-app'
import { getPkgManager } from './helpers/get-pkg-manager'
import { validateNpmName } from './helpers/validate-pkg'
import packageJson from './package.json'
import ciInfo from 'ci-info'
let projectPath: string = ''
const program = new Commander.Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action((name) => {
projectPath = name
})
.option(
'--ts, --typescript',
`
Initialize as a TypeScript project. (default)
`
)
.option(
'--js, --javascript',
`
Initialize as a JavaScript project.
`
)
.option(
'--experimental-app',
`
Initialize as a \`app/\` directory project.
`
)
.option(
'--use-npm',
`
Explicitly tell the CLI to bootstrap the app using npm
`
)
.option(
'--use-pnpm',
`
Explicitly tell the CLI to bootstrap the app using pnpm
`
)
.option(
'-e, --example [name]|[github-url]',
`
An example to bootstrap the app with. You can use an example name
from the official Next.js repo or a GitHub URL. The URL can use
any branch and/or subdirectory
`
)
.option(
'--example-path <path-to-example>',
`
In a rare case, your GitHub URL might contain a branch name with
a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar).
In this case, you must specify the path to the example separately:
--example-path foo/bar
`
)
.allowUnknownOption()
.parse(process.argv)
const packageManager = !!program.useNpm
? 'npm'
: !!program.usePnpm
? 'pnpm'
: getPkgManager()
async function run(): Promise<void> {
if (typeof projectPath === 'string') {
projectPath = projectPath.trim()
}
if (!projectPath) {
const res = await prompts({
type: 'text',
name: 'path',
message: 'What is your project named?',
initial: 'my-app',
validate: (name) => {
const validation = validateNpmName(path.basename(path.resolve(name)))
if (validation.valid) {
return true
}
return 'Invalid project name: ' + validation.problems![0]
},
})
if (typeof res.path === 'string') {
projectPath = res.path.trim()
}
}
if (!projectPath) {
console.log(
'\nPlease specify the project directory:\n' +
` ${chalk.cyan(program.name())} ${chalk.green(
'<project-directory>'
)}\n` +
'For example:\n' +
` ${chalk.cyan(program.name())} ${chalk.green('my-next-app')}\n\n` +
`Run ${chalk.cyan(`${program.name()} --help`)} to see all options.`
)
process.exit(1)
}
const resolvedProjectPath = path.resolve(projectPath)
const projectName = path.basename(resolvedProjectPath)
const { valid, problems } = validateNpmName(projectName)
if (!valid) {
console.error(
`Could not create a project called ${chalk.red(
`"${projectName}"`
)} because of npm naming restrictions:`
)
problems!.forEach((p) => console.error(` ${chalk.red.bold('*')} ${p}`))
process.exit(1)
}
if (program.example === true) {
console.error(
'Please provide an example name or url, otherwise remove the example option.'
)
process.exit(1)
}
const example = typeof program.example === 'string' && program.example.trim()
/**
* If the user does not provide the necessary flags, prompt them for whether
* to use TS or JS.
*
* @todo Allow appDir to support TS or JS, currently TS-only and disables all
* --ts, --js features.
*/
if (!example && !program.typescript && !program.javascript) {
if (ciInfo.isCI) {
// default to JavaScript in CI as we can't prompt to
// prevent breaking setup flows
program.javascript = true
program.typescript = false
} else {
const styledTypeScript = chalk.hex('#007acc')('TypeScript')
const { typescript } = await prompts(
{
type: 'toggle',
name: 'typescript',
message: `Would you like to use ${styledTypeScript} with this project?`,
initial: true,
active: 'Yes',
inactive: 'No',
},
{
/**
* User inputs Ctrl+C or Ctrl+D to exit the prompt. We should close the
* process and not write to the file system.
*/
onCancel: () => {
console.error('Exiting.')
process.exit(1)
},
}
)
/**
* Depending on the prompt response, set the appropriate program flags.
*/
program.typescript = Boolean(typescript)
program.javascript = !Boolean(typescript)
}
}
try {
await createApp({
appPath: resolvedProjectPath,
packageManager,
example: example && example !== 'default' ? example : undefined,
examplePath: program.examplePath,
typescript: program.typescript,
experimentalApp: program.experimentalApp,
})
} catch (reason) {
if (!(reason instanceof DownloadError)) {
throw reason
}
const res = await prompts({
type: 'confirm',
name: 'builtin',
message:
`Could not download "${example}" because of a connectivity issue between your machine and GitHub.\n` +
`Do you want to use the default template instead?`,
initial: true,
})
if (!res.builtin) {
throw reason
}
await createApp({
appPath: resolvedProjectPath,
packageManager,
typescript: program.typescript,
experimentalApp: program.experimentalApp,
})
}
}
const update = checkForUpdate(packageJson).catch(() => null)
async function notifyUpdate(): Promise<void> {
try {
const res = await update
if (res?.latest) {
const updateMessage =
packageManager === 'yarn'
? 'yarn global add create-next-app'
: packageManager === 'pnpm'
? 'pnpm add -g create-next-app'
: 'npm i -g create-next-app'
console.log(
chalk.yellow.bold('A new version of `create-next-app` is available!') +
'\n' +
'You can update by running: ' +
chalk.cyan(updateMessage) +
'\n'
)
}
process.exit()
} catch {
// ignore error
}
}
run()
.then(notifyUpdate)
.catch(async (reason) => {
console.log()
console.log('Aborting installation.')
if (reason.command) {
console.log(` ${chalk.cyan(reason.command)} has failed.`)
} else {
console.log(
chalk.red('Unexpected error. Please report it as a bug:') + '\n',
reason
)
}
console.log()
await notifyUpdate()
process.exit(1)
})