diff options
Diffstat (limited to 'packages/create-turbo/src/commands/create')
| -rw-r--r-- | packages/create-turbo/src/commands/create/createProject.ts | 192 | ||||
| -rw-r--r-- | packages/create-turbo/src/commands/create/index.ts | 243 | ||||
| -rw-r--r-- | packages/create-turbo/src/commands/create/prompts.ts | 124 | ||||
| -rw-r--r-- | packages/create-turbo/src/commands/create/types.ts | 8 |
4 files changed, 567 insertions, 0 deletions
diff --git a/packages/create-turbo/src/commands/create/createProject.ts b/packages/create-turbo/src/commands/create/createProject.ts new file mode 100644 index 0000000..0c1d2ac --- /dev/null +++ b/packages/create-turbo/src/commands/create/createProject.ts @@ -0,0 +1,192 @@ +import retry from "async-retry"; +import chalk from "chalk"; +import fs from "fs-extra"; +import path from "path"; + +import { + downloadAndExtractExample, + downloadAndExtractRepo, + getRepoInfo, + existsInRepo, + hasRepo, + RepoInfo, +} from "../../utils/examples"; +import { isFolderEmpty } from "../../utils/isFolderEmpty"; +import { isWriteable } from "../../utils/isWriteable"; +import { turboLoader, error } from "../../logger"; +import { isDefaultExample } from "../../utils/isDefaultExample"; + +export class DownloadError extends Error {} + +export async function createProject({ + appPath, + example, + examplePath, +}: { + appPath: string; + example: string; + examplePath?: string; +}): Promise<{ + cdPath: string; + hasPackageJson: boolean; + availableScripts: Array<string>; + repoInfo?: RepoInfo; +}> { + let repoInfo: RepoInfo | undefined; + let repoUrl: URL | undefined; + const defaultExample = isDefaultExample(example); + + try { + repoUrl = new URL(example); + } catch (err: any) { + if (err.code !== "ERR_INVALID_URL") { + error(err); + process.exit(1); + } + } + + if (repoUrl) { + if (repoUrl.origin !== "https://github.com") { + error( + `Invalid URL: ${chalk.red( + `"${example}"` + )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` + ); + process.exit(1); + } + + repoInfo = await getRepoInfo(repoUrl, examplePath); + + if (!repoInfo) { + error( + `Unable to fetch repository information from: ${chalk.red( + `"${example}"` + )}. Please fix the URL and try again.` + ); + process.exit(1); + } + + const found = await hasRepo(repoInfo); + + if (!found) { + error( + `Could not locate the repository for ${chalk.red( + `"${example}"` + )}. Please check that the repository exists and try again.` + ); + process.exit(1); + } + } else { + const found = await existsInRepo(example); + + if (!found) { + error( + `Could not locate an example named ${chalk.red( + `"${example}"` + )}. It could be due to the following:\n`, + `1. Your spelling of example ${chalk.red( + `"${example}"` + )} might be incorrect.\n`, + `2. You might not be connected to the internet or you are behind a proxy.` + ); + process.exit(1); + } + } + + const root = path.resolve(appPath); + + if (!(await isWriteable(path.dirname(root)))) { + error( + "The application path is not writable, please check folder permissions and try again." + ); + error("It is likely you do not have write permissions for this folder."); + process.exit(1); + } + + const appName = path.basename(root); + try { + await fs.mkdir(root, { recursive: true }); + } catch (err) { + error("Unable to create project directory"); + console.error(err); + process.exit(1); + } + const { isEmpty, conflicts } = isFolderEmpty(root); + if (!isEmpty) { + error( + `${chalk.dim(root)} has ${conflicts.length} conflicting ${ + conflicts.length === 1 ? "file" : "files" + } - please try a different location` + ); + process.exit(1); + } + + const originalDirectory = process.cwd(); + process.chdir(root); + + /** + * clone the example repository + */ + const loader = turboLoader("Downloading files..."); + try { + if (repoInfo) { + console.log( + `\nDownloading files from repo ${chalk.cyan( + example + )}. This might take a moment.` + ); + console.log(); + loader.start(); + await retry(() => downloadAndExtractRepo(root, repoInfo as RepoInfo), { + retries: 3, + }); + } else { + console.log( + `\nDownloading files${ + !defaultExample ? ` for example ${chalk.cyan(example)}` : "" + }. This might take a moment.` + ); + console.log(); + loader.start(); + await retry(() => downloadAndExtractExample(root, example), { + retries: 3, + }); + } + } catch (reason) { + function isErrorLike(err: unknown): err is { message: string } { + return ( + typeof err === "object" && + err !== null && + typeof (err as { message?: unknown }).message === "string" + ); + } + throw new DownloadError(isErrorLike(reason) ? reason.message : reason + ""); + } finally { + loader.stop(); + } + + const rootPackageJsonPath = path.join(root, "package.json"); + const hasPackageJson = fs.existsSync(rootPackageJsonPath); + const availableScripts = []; + + if (hasPackageJson) { + let packageJsonContent; + try { + packageJsonContent = fs.readJsonSync(rootPackageJsonPath); + } catch { + // ignore + } + + if (packageJsonContent) { + // read the scripts from the package.json + availableScripts.push(...Object.keys(packageJsonContent.scripts || {})); + } + } + + let cdPath: string = appPath; + if (path.join(originalDirectory, appName) === appPath) { + cdPath = appName; + } + + return { cdPath, hasPackageJson, availableScripts, repoInfo }; +} diff --git a/packages/create-turbo/src/commands/create/index.ts b/packages/create-turbo/src/commands/create/index.ts new file mode 100644 index 0000000..419328b --- /dev/null +++ b/packages/create-turbo/src/commands/create/index.ts @@ -0,0 +1,243 @@ +import path from "path"; +import chalk from "chalk"; +import type { Project } from "@turbo/workspaces"; +import { + getWorkspaceDetails, + install, + getPackageManagerMeta, + ConvertError, +} from "@turbo/workspaces"; +import { getAvailablePackageManagers } from "@turbo/utils"; +import type { CreateCommandArgument, CreateCommandOptions } from "./types"; +import * as prompts from "./prompts"; +import { createProject } from "./createProject"; +import { tryGitCommit, tryGitInit } from "../../utils/git"; +import { isOnline } from "../../utils/isOnline"; +import { transforms } from "../../transforms"; +import { turboGradient, turboLoader, info, error, warn } from "../../logger"; +import { TransformError } from "../../transforms/errors"; + +function handleErrors(err: unknown) { + // handle errors from ../../transforms + if (err instanceof TransformError) { + error(chalk.bold(err.transform), chalk.red(err.message)); + if (err.fatal) { + process.exit(1); + } + // handle errors from @turbo/workspaces + } else if (err instanceof ConvertError && err.type !== "unknown") { + error(chalk.red(err.message)); + process.exit(1); + // handle unknown errors (no special handling, just re-throw to catch at root) + } else { + throw err; + } +} + +const SCRIPTS_TO_DISPLAY: Record<string, string> = { + build: "Build", + dev: "Develop", + test: "Test", + lint: "Lint", +}; + +export async function create( + directory: CreateCommandArgument, + packageManager: CreateCommandArgument, + opts: CreateCommandOptions +) { + const { skipInstall, skipTransforms } = opts; + console.log(chalk.bold(turboGradient(`\n>>> TURBOREPO\n`))); + info(`Welcome to Turborepo! Let's get you set up with a new codebase.`); + console.log(); + + const [online, availablePackageManagers] = await Promise.all([ + isOnline(), + getAvailablePackageManagers(), + ]); + + if (!online) { + error( + "You appear to be offline. Please check your network connection and try again." + ); + process.exit(1); + } + const { root, projectName } = await prompts.directory({ directory }); + const relativeProjectDir = path.relative(process.cwd(), root); + const projectDirIsCurrentDir = relativeProjectDir === ""; + + // selected package manager can be undefined if the user chooses to skip transforms + const selectedPackageManagerDetails = await prompts.packageManager({ + packageManager, + skipTransforms, + }); + + if (packageManager && opts.skipTransforms) { + warn( + "--skip-transforms conflicts with <package-manager>. The package manager argument will be ignored." + ); + } + + const { example, examplePath } = opts; + const exampleName = example && example !== "default" ? example : "basic"; + const { hasPackageJson, availableScripts, repoInfo } = await createProject({ + appPath: root, + example: exampleName, + examplePath, + }); + + // create a new git repo after creating the project + tryGitInit(root, `feat(create-turbo): create ${exampleName}`); + + // read the project after creating it to get details about workspaces, package manager, etc. + let project: Project = {} as Project; + try { + project = await getWorkspaceDetails({ root }); + } catch (err) { + handleErrors(err); + } + + // run any required transforms + if (!skipTransforms) { + for (const transform of transforms) { + try { + const transformResult = await transform({ + example: { + repo: repoInfo, + name: exampleName, + }, + project, + prompts: { + projectName, + root, + packageManager: selectedPackageManagerDetails, + }, + opts, + }); + if (transformResult.result === "success") { + tryGitCommit( + `feat(create-turbo): apply ${transformResult.name} transform` + ); + } + } catch (err) { + handleErrors(err); + } + } + } + + // if the user opted out of transforms, the package manager will be the same as the source example + const projectPackageManager = + skipTransforms || !selectedPackageManagerDetails + ? { + name: project.packageManager, + version: availablePackageManagers[project.packageManager].version, + } + : selectedPackageManagerDetails; + + info("Created a new Turborepo with the following:"); + console.log(); + if (project.workspaceData.workspaces.length > 0) { + const workspacesForDisplay = project.workspaceData.workspaces + .map((w) => ({ + group: path.relative(root, w.paths.root).split(path.sep)?.[0] || "", + title: path.relative(root, w.paths.root), + description: w.description, + })) + .sort((a, b) => a.title.localeCompare(b.title)); + + let lastGroup: string | undefined; + workspacesForDisplay.forEach(({ group, title, description }, idx) => { + if (idx === 0 || group !== lastGroup) { + console.log(chalk.cyan(group)); + } + console.log( + ` - ${chalk.bold(title)}${description ? `: ${description}` : ""}` + ); + lastGroup = group; + }); + } else { + console.log(chalk.cyan("apps")); + console.log(` - ${chalk.bold(projectName)}`); + } + + // run install + console.log(); + if (hasPackageJson && !skipInstall) { + // in the case when the user opted out of transforms, but not install, we need to make sure the package manager is available + // before we attempt an install + if ( + opts.skipTransforms && + !availablePackageManagers[project.packageManager].available + ) { + warn( + `Unable to install dependencies - "${exampleName}" uses "${project.packageManager}" which could not be found.` + ); + warn( + `Try running without "--skip-transforms" to convert "${exampleName}" to a package manager that is available on your system.` + ); + console.log(); + } else if (projectPackageManager) { + console.log("Installing packages. This might take a couple of minutes."); + console.log(); + + const loader = turboLoader("Installing dependencies...").start(); + await install({ + project, + to: projectPackageManager, + options: { + interactive: false, + }, + }); + + tryGitCommit("feat(create-turbo): install dependencies"); + loader.stop(); + } + } + + if (projectDirIsCurrentDir) { + console.log( + `${chalk.bold( + turboGradient(">>> Success!") + )} Your new Turborepo is ready.` + ); + } else { + console.log( + `${chalk.bold( + turboGradient(">>> Success!") + )} Created a new Turborepo at "${relativeProjectDir}".` + ); + } + + // get the package manager details so we display the right commands to the user in log messages + const packageManagerMeta = getPackageManagerMeta(projectPackageManager); + if (packageManagerMeta && hasPackageJson) { + console.log( + `Inside ${ + projectDirIsCurrentDir ? "this" : "that" + } directory, you can run several commands:` + ); + console.log(); + availableScripts + .filter((script) => SCRIPTS_TO_DISPLAY[script]) + .forEach((script) => { + console.log( + chalk.cyan(` ${packageManagerMeta.command} run ${script}`) + ); + console.log(` ${SCRIPTS_TO_DISPLAY[script]} all apps and packages`); + console.log(); + }); + console.log(`Turborepo will cache locally by default. For an additional`); + console.log(`speed boost, enable Remote Caching with Vercel by`); + console.log(`entering the following command:`); + console.log(); + console.log(chalk.cyan(` ${packageManagerMeta.executable} turbo login`)); + console.log(); + console.log(`We suggest that you begin by typing:`); + console.log(); + if (!projectDirIsCurrentDir) { + console.log(` ${chalk.cyan("cd")} ${relativeProjectDir}`); + } + console.log(chalk.cyan(` ${packageManagerMeta.executable} turbo login`)); + console.log(); + } +} diff --git a/packages/create-turbo/src/commands/create/prompts.ts b/packages/create-turbo/src/commands/create/prompts.ts new file mode 100644 index 0000000..a5ed7bf --- /dev/null +++ b/packages/create-turbo/src/commands/create/prompts.ts @@ -0,0 +1,124 @@ +import path from "path"; +import fs from "fs-extra"; +import chalk from "chalk"; +import type { PackageManager } from "@turbo/workspaces"; +import type { CreateCommandArgument } from "./types"; +import { getAvailablePackageManagers } from "@turbo/utils"; +import { isFolderEmpty } from "../../utils/isFolderEmpty"; +import inquirer from "inquirer"; + +function validateDirectory(directory: string): { + valid: boolean; + root: string; + projectName: string; + error?: string; +} { + const root = path.resolve(directory); + const projectName = path.basename(root); + const exists = fs.existsSync(root); + + const stat = fs.lstatSync(root, { throwIfNoEntry: false }); + if (stat && !stat.isDirectory()) { + return { + valid: false, + root, + projectName, + error: `${chalk.dim( + projectName + )} is not a directory - please try a different location`, + }; + } + + if (exists) { + const { isEmpty, conflicts } = isFolderEmpty(root); + if (!isEmpty) { + return { + valid: false, + root, + projectName, + error: `${chalk.dim(projectName)} has ${conflicts.length} conflicting ${ + conflicts.length === 1 ? "file" : "files" + } - please try a different location`, + }; + } + } + + return { valid: true, root, projectName }; +} + +export async function directory({ + directory, +}: { + directory: CreateCommandArgument; +}) { + const projectDirectoryAnswer = await inquirer.prompt<{ + projectDirectory: string; + }>({ + type: "input", + name: "projectDirectory", + message: "Where would you like to create your turborepo?", + when: !directory, + default: "./my-turborepo", + validate: (directory: string) => { + const { valid, error } = validateDirectory(directory); + if (!valid && error) { + return error; + } + return true; + }, + filter: (directory: string) => directory.trim(), + }); + + const { projectDirectory: selectedProjectDirectory = directory as string } = + projectDirectoryAnswer; + + return validateDirectory(selectedProjectDirectory); +} + +export async function packageManager({ + packageManager, + skipTransforms, +}: { + packageManager: CreateCommandArgument; + skipTransforms?: boolean; +}) { + // if skip transforms is passed, we don't need to ask about the package manager (because that requires a transform) + if (skipTransforms) { + return undefined; + } + + const availablePackageManagers = await getAvailablePackageManagers(); + const packageManagerAnswer = await inquirer.prompt<{ + packageManagerInput?: PackageManager; + }>({ + name: "packageManagerInput", + type: "list", + message: "Which package manager do you want to use?", + when: + // prompt for package manager if it wasn't provided as an argument, or if it was + // provided, but isn't available (always allow npm) + !packageManager || + (packageManager as PackageManager) !== "npm" || + !Object.keys(availablePackageManagers).includes(packageManager), + choices: ["npm", "pnpm", "yarn"].map((p) => ({ + name: p, + value: p, + disabled: + // npm should always be available + p === "npm" || + availablePackageManagers?.[p as PackageManager]?.available + ? false + : `not installed`, + })), + }); + + const { + packageManagerInput: + selectedPackageManager = packageManager as PackageManager, + } = packageManagerAnswer; + + return { + name: selectedPackageManager, + version: availablePackageManagers[selectedPackageManager].version, + }; +} diff --git a/packages/create-turbo/src/commands/create/types.ts b/packages/create-turbo/src/commands/create/types.ts new file mode 100644 index 0000000..094c8d2 --- /dev/null +++ b/packages/create-turbo/src/commands/create/types.ts @@ -0,0 +1,8 @@ +export type CreateCommandArgument = "string" | undefined; + +export interface CreateCommandOptions { + skipInstall?: boolean; + skipTransforms?: boolean; + example?: string; + examplePath?: string; +} |
