aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/create-turbo/src/commands
diff options
context:
space:
mode:
Diffstat (limited to 'packages/create-turbo/src/commands')
-rw-r--r--packages/create-turbo/src/commands/create/createProject.ts192
-rw-r--r--packages/create-turbo/src/commands/create/index.ts243
-rw-r--r--packages/create-turbo/src/commands/create/prompts.ts124
-rw-r--r--packages/create-turbo/src/commands/create/types.ts8
-rw-r--r--packages/create-turbo/src/commands/index.ts1
5 files changed, 568 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;
+}
diff --git a/packages/create-turbo/src/commands/index.ts b/packages/create-turbo/src/commands/index.ts
new file mode 100644
index 0000000..7c5f96b
--- /dev/null
+++ b/packages/create-turbo/src/commands/index.ts
@@ -0,0 +1 @@
+export { create } from "./create";