aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/create-turbo/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/create-turbo/src')
-rw-r--r--packages/create-turbo/src/cli.ts65
-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
-rw-r--r--packages/create-turbo/src/logger.ts32
-rw-r--r--packages/create-turbo/src/transforms/errors.ts17
-rw-r--r--packages/create-turbo/src/transforms/git-ignore.ts30
-rw-r--r--packages/create-turbo/src/transforms/index.ts13
-rw-r--r--packages/create-turbo/src/transforms/official-starter.ts73
-rw-r--r--packages/create-turbo/src/transforms/package-manager.ts26
-rw-r--r--packages/create-turbo/src/transforms/types.ts30
-rw-r--r--packages/create-turbo/src/utils/examples.ts139
-rw-r--r--packages/create-turbo/src/utils/git.ts90
-rw-r--r--packages/create-turbo/src/utils/isDefaultExample.ts5
-rw-r--r--packages/create-turbo/src/utils/isFolderEmpty.ts37
-rw-r--r--packages/create-turbo/src/utils/isOnline.ts40
-rw-r--r--packages/create-turbo/src/utils/isWriteable.ts10
-rw-r--r--packages/create-turbo/src/utils/notifyUpdate.ts22
20 files changed, 1197 insertions, 0 deletions
diff --git a/packages/create-turbo/src/cli.ts b/packages/create-turbo/src/cli.ts
new file mode 100644
index 0000000..1290a13
--- /dev/null
+++ b/packages/create-turbo/src/cli.ts
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+
+import chalk from "chalk";
+import { Command } from "commander";
+import notifyUpdate from "./utils/notifyUpdate";
+import { turboGradient, error } from "./logger";
+
+import { create } from "./commands";
+import cliPkg from "../package.json";
+
+const createTurboCli = new Command();
+
+// create
+createTurboCli
+ .name(chalk.bold(turboGradient("create-turbo")))
+ .description("Create a new Turborepo")
+ .usage(`${chalk.bold("<project-directory> <package-manager>")} [options]`)
+ .argument("[project-directory]")
+ .argument("[package-manager]")
+ .option(
+ "--skip-install",
+ "Do not run a package manager install after creating the project",
+ false
+ )
+ .option(
+ "--skip-transforms",
+ "Do not run any code transformation after creating the project",
+ false
+ )
+ .option(
+ "-e, --example [name]|[github-url]",
+ `
+ An example to bootstrap the app with. You can use an example name
+ from the official Turborepo repo or a GitHub URL. The URL can use
+ any branch and/or subdirectory
+`
+ )
+ .option(
+ "-p, --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
+`
+ )
+ .version(cliPkg.version, "-v, --version", "output the current version")
+ .helpOption()
+ .action(create);
+
+createTurboCli
+ .parseAsync()
+ .then(notifyUpdate)
+ .catch(async (reason) => {
+ console.log();
+ if (reason.command) {
+ error(`${chalk.bold(reason.command)} has failed.`);
+ } else {
+ error("Unexpected error. Please report it as a bug:");
+ console.log(reason);
+ }
+ console.log();
+ await notifyUpdate();
+ process.exit(1);
+ });
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";
diff --git a/packages/create-turbo/src/logger.ts b/packages/create-turbo/src/logger.ts
new file mode 100644
index 0000000..ee6d584
--- /dev/null
+++ b/packages/create-turbo/src/logger.ts
@@ -0,0 +1,32 @@
+import chalk from "chalk";
+import ora from "ora";
+import gradient from "gradient-string";
+
+const BLUE = "#0099F7";
+const RED = "#F11712";
+const YELLOW = "#FFFF00";
+
+export const turboGradient = gradient(BLUE, RED);
+export const turboBlue = chalk.hex(BLUE);
+export const turboRed = chalk.hex(RED);
+export const yellow = chalk.hex(YELLOW);
+
+export const turboLoader = (text: string) =>
+ ora({
+ text,
+ spinner: {
+ frames: [" ", turboBlue("> "), turboBlue(">> "), turboBlue(">>>")],
+ },
+ });
+
+export const info = (...args: any[]) => {
+ console.log(turboBlue.bold(">>>"), ...args);
+};
+
+export const error = (...args: any[]) => {
+ console.error(turboRed.bold(">>>"), ...args);
+};
+
+export const warn = (...args: any[]) => {
+ console.error(yellow.bold(">>>"), ...args);
+};
diff --git a/packages/create-turbo/src/transforms/errors.ts b/packages/create-turbo/src/transforms/errors.ts
new file mode 100644
index 0000000..a5b8a7a
--- /dev/null
+++ b/packages/create-turbo/src/transforms/errors.ts
@@ -0,0 +1,17 @@
+export type TransformErrorOptions = {
+ transform?: string;
+ fatal?: boolean;
+};
+
+export class TransformError extends Error {
+ public transform: string;
+ public fatal: boolean;
+
+ constructor(message: string, opts?: TransformErrorOptions) {
+ super(message);
+ this.name = "TransformError";
+ this.transform = opts?.transform ?? "unknown";
+ this.fatal = opts?.fatal ?? true;
+ Error.captureStackTrace(this, TransformError);
+ }
+}
diff --git a/packages/create-turbo/src/transforms/git-ignore.ts b/packages/create-turbo/src/transforms/git-ignore.ts
new file mode 100644
index 0000000..bb61ca7
--- /dev/null
+++ b/packages/create-turbo/src/transforms/git-ignore.ts
@@ -0,0 +1,30 @@
+import path from "path";
+import fs from "fs-extra";
+import { DEFAULT_IGNORE } from "../utils/git";
+import { TransformInput, TransformResult } from "./types";
+import { TransformError } from "./errors";
+
+const meta = {
+ name: "git-ignore",
+};
+
+export async function transform(args: TransformInput): TransformResult {
+ const { prompts } = args;
+ const ignorePath = path.join(prompts.root, ".gitignore");
+ try {
+ if (!fs.existsSync(ignorePath)) {
+ fs.writeFileSync(ignorePath, DEFAULT_IGNORE);
+ } else {
+ return { result: "not-applicable", ...meta };
+ }
+ } catch (err) {
+ // existsSync cannot throw, so we don't need to narrow here and can
+ // assume this came from writeFileSync
+ throw new TransformError("Unable to write .gitignore", {
+ transform: meta.name,
+ fatal: false,
+ });
+ }
+
+ return { result: "success", ...meta };
+}
diff --git a/packages/create-turbo/src/transforms/index.ts b/packages/create-turbo/src/transforms/index.ts
new file mode 100644
index 0000000..1918ecc
--- /dev/null
+++ b/packages/create-turbo/src/transforms/index.ts
@@ -0,0 +1,13 @@
+import { transform as packageManagerTransform } from "./package-manager";
+import { transform as officialStarter } from "./official-starter";
+import { transform as gitIgnoreTransform } from "./git-ignore";
+import type { TransformInput, TransformResult } from "./types";
+
+/**
+ * In the future, we may want to support sourcing additional transforms from the templates themselves.
+ */
+export const transforms: Array<(args: TransformInput) => TransformResult> = [
+ officialStarter,
+ gitIgnoreTransform,
+ packageManagerTransform,
+];
diff --git a/packages/create-turbo/src/transforms/official-starter.ts b/packages/create-turbo/src/transforms/official-starter.ts
new file mode 100644
index 0000000..1d71909
--- /dev/null
+++ b/packages/create-turbo/src/transforms/official-starter.ts
@@ -0,0 +1,73 @@
+import path from "path";
+import fs from "fs-extra";
+import semverPrerelease from "semver/functions/prerelease";
+import cliPkgJson from "../../package.json";
+import { isDefaultExample } from "../utils/isDefaultExample";
+import { TransformInput, TransformResult } from "./types";
+import { TransformError } from "./errors";
+
+const meta = {
+ name: "official-starter",
+};
+
+// applied to "official starter" examples (those hosted within vercel/turbo/examples)
+export async function transform(args: TransformInput): TransformResult {
+ const { prompts, example } = args;
+
+ const defaultExample = isDefaultExample(example.name);
+ const isOfficialStarter =
+ !example.repo ||
+ (example.repo?.username === "vercel" && example.repo?.name === "turbo");
+
+ if (!isOfficialStarter) {
+ return { result: "not-applicable", ...meta };
+ }
+
+ // paths
+ const rootPackageJsonPath = path.join(prompts.root, "package.json");
+ const rootMetaJsonPath = path.join(prompts.root, "meta.json");
+ const hasPackageJson = fs.existsSync(rootPackageJsonPath);
+
+ // 1. remove meta file (used for generating the examples page on turbo.build)
+ try {
+ fs.rmSync(rootMetaJsonPath, { force: true });
+ } catch (_err) {}
+
+ if (hasPackageJson) {
+ let packageJsonContent;
+ try {
+ packageJsonContent = fs.readJsonSync(rootPackageJsonPath);
+ } catch {
+ throw new TransformError("Unable to read package.json", {
+ transform: meta.name,
+ fatal: false,
+ });
+ }
+
+ // if using the basic example, set the name to the project name (legacy behavior)
+ if (packageJsonContent) {
+ if (defaultExample) {
+ packageJsonContent.name = prompts.projectName;
+ }
+
+ // if we're using a pre-release version of create-turbo, install turbo canary instead of latest
+ const shouldUsePreRelease = semverPrerelease(cliPkgJson.version) !== null;
+ if (shouldUsePreRelease && packageJsonContent?.devDependencies?.turbo) {
+ packageJsonContent.devDependencies.turbo = "canary";
+ }
+
+ try {
+ fs.writeJsonSync(rootPackageJsonPath, packageJsonContent, {
+ spaces: 2,
+ });
+ } catch (err) {
+ throw new TransformError("Unable to write package.json", {
+ transform: meta.name,
+ fatal: false,
+ });
+ }
+ }
+ }
+
+ return { result: "success", ...meta };
+}
diff --git a/packages/create-turbo/src/transforms/package-manager.ts b/packages/create-turbo/src/transforms/package-manager.ts
new file mode 100644
index 0000000..9c0af24
--- /dev/null
+++ b/packages/create-turbo/src/transforms/package-manager.ts
@@ -0,0 +1,26 @@
+import { convert } from "@turbo/workspaces";
+import { TransformInput, TransformResult } from "./types";
+
+const meta = {
+ name: "package-manager",
+};
+
+export async function transform(args: TransformInput): TransformResult {
+ const { project, prompts } = args;
+ const { root, packageManager } = prompts;
+
+ if (packageManager && project.packageManager !== packageManager.name) {
+ await convert({
+ root,
+ to: packageManager.name,
+ options: {
+ // skip install after conversion- we will do it later
+ skipInstall: true,
+ },
+ });
+ } else {
+ return { result: "not-applicable", ...meta };
+ }
+
+ return { result: "success", ...meta };
+}
diff --git a/packages/create-turbo/src/transforms/types.ts b/packages/create-turbo/src/transforms/types.ts
new file mode 100644
index 0000000..6a8e141
--- /dev/null
+++ b/packages/create-turbo/src/transforms/types.ts
@@ -0,0 +1,30 @@
+import { CreateCommandOptions } from "../commands/create/types";
+import { RepoInfo } from "../utils/examples";
+import type { Project, PackageManager } from "@turbo/workspaces";
+
+export interface TransformInput {
+ example: {
+ repo: RepoInfo | undefined;
+ name: string;
+ };
+ project: Project;
+ prompts: {
+ projectName: string;
+ root: string;
+ packageManager:
+ | {
+ name: PackageManager;
+ version: string | undefined;
+ }
+ | undefined;
+ };
+ opts: CreateCommandOptions;
+}
+
+export interface TransformResponse {
+ // errors should be thrown as instances of TransformError
+ result: "not-applicable" | "success";
+ name: string;
+}
+
+export type TransformResult = Promise<TransformResponse>;
diff --git a/packages/create-turbo/src/utils/examples.ts b/packages/create-turbo/src/utils/examples.ts
new file mode 100644
index 0000000..b7c4812
--- /dev/null
+++ b/packages/create-turbo/src/utils/examples.ts
@@ -0,0 +1,139 @@
+import got from "got";
+import tar from "tar";
+import { Stream } from "stream";
+import { promisify } from "util";
+import { join } from "path";
+import { tmpdir } from "os";
+import { createWriteStream, promises as fs } from "fs";
+
+const pipeline = promisify(Stream.pipeline);
+
+export type RepoInfo = {
+ username: string;
+ name: string;
+ branch: string;
+ filePath: string;
+};
+
+export async function isUrlOk(url: string): Promise<boolean> {
+ try {
+ const res = await got.head(url);
+ return res.statusCode === 200;
+ } catch (err) {
+ return false;
+ }
+}
+
+export async function getRepoInfo(
+ url: URL,
+ examplePath?: string
+): Promise<RepoInfo | undefined> {
+ const [, username, name, tree, sourceBranch, ...file] =
+ url.pathname.split("/");
+ const filePath = examplePath
+ ? examplePath.replace(/^\//, "")
+ : file.join("/");
+
+ if (
+ // Support repos whose entire purpose is to be a Turborepo example, e.g.
+ // https://github.com/:username/:my-cool-turborepo-example-repo-name.
+ tree === undefined ||
+ // Support GitHub URL that ends with a trailing slash, e.g.
+ // https://github.com/:username/:my-cool-turborepo-example-repo-name/
+ // In this case "t" will be an empty string while the turbo part "_branch" will be undefined
+ (tree === "" && sourceBranch === undefined)
+ ) {
+ try {
+ const infoResponse = await got(
+ `https://api.github.com/repos/${username}/${name}`
+ );
+ const info = JSON.parse(infoResponse.body);
+ return { username, name, branch: info["default_branch"], filePath };
+ } catch (err) {
+ return;
+ }
+ }
+
+ // If examplePath is available, the branch name takes the entire path
+ const branch = examplePath
+ ? `${sourceBranch}/${file.join("/")}`.replace(
+ new RegExp(`/${filePath}|/$`),
+ ""
+ )
+ : sourceBranch;
+
+ if (username && name && branch && tree === "tree") {
+ return { username, name, branch, filePath };
+ }
+}
+
+export function hasRepo({
+ username,
+ name,
+ branch,
+ filePath,
+}: RepoInfo): Promise<boolean> {
+ const contentsUrl = `https://api.github.com/repos/${username}/${name}/contents`;
+ const packagePath = `${filePath ? `/${filePath}` : ""}/package.json`;
+
+ return isUrlOk(contentsUrl + packagePath + `?ref=${branch}`);
+}
+
+export function existsInRepo(nameOrUrl: string): Promise<boolean> {
+ try {
+ const url = new URL(nameOrUrl);
+ return isUrlOk(url.href);
+ } catch {
+ return isUrlOk(
+ `https://api.github.com/repos/vercel/turbo/contents/examples/${encodeURIComponent(
+ nameOrUrl
+ )}`
+ );
+ }
+}
+
+async function downloadTar(url: string, name: string) {
+ const tempFile = join(tmpdir(), `${name}.temp-${Date.now()}`);
+ await pipeline(got.stream(url), createWriteStream(tempFile));
+ return tempFile;
+}
+
+export async function downloadAndExtractRepo(
+ root: string,
+ { username, name, branch, filePath }: RepoInfo
+) {
+ const tempFile = await downloadTar(
+ `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`,
+ `turbo-ct-example`
+ );
+
+ await tar.x({
+ file: tempFile,
+ cwd: root,
+ strip: filePath ? filePath.split("/").length + 1 : 1,
+ filter: (p: string) =>
+ p.startsWith(
+ `${name}-${branch.replace(/\//g, "-")}${
+ filePath ? `/${filePath}/` : "/"
+ }`
+ ),
+ });
+
+ await fs.unlink(tempFile);
+}
+
+export async function downloadAndExtractExample(root: string, name: string) {
+ const tempFile = await downloadTar(
+ `https://codeload.github.com/vercel/turbo/tar.gz/main`,
+ `turbo-ct-example`
+ );
+
+ await tar.x({
+ file: tempFile,
+ cwd: root,
+ strip: 2 + name.split("/").length,
+ filter: (p: string) => p.includes(`turbo-main/examples/${name}/`),
+ });
+
+ await fs.unlink(tempFile);
+}
diff --git a/packages/create-turbo/src/utils/git.ts b/packages/create-turbo/src/utils/git.ts
new file mode 100644
index 0000000..593e7ea
--- /dev/null
+++ b/packages/create-turbo/src/utils/git.ts
@@ -0,0 +1,90 @@
+import fs from "fs-extra";
+import { execSync } from "child_process";
+import path from "path";
+import rimraf from "rimraf";
+
+export const DEFAULT_IGNORE = `
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+node_modules
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# turbo
+.turbo
+
+# vercel
+.vercel
+`;
+
+export const GIT_REPO_COMMAND = "git rev-parse --is-inside-work-tree";
+export const HG_REPO_COMMAND = "hg --cwd . root";
+
+export function isInGitRepository(): boolean {
+ try {
+ execSync(GIT_REPO_COMMAND, { stdio: "ignore" });
+ return true;
+ } catch (_) {}
+ return false;
+}
+
+export function isInMercurialRepository(): boolean {
+ try {
+ execSync(HG_REPO_COMMAND, { stdio: "ignore" });
+ return true;
+ } catch (_) {}
+ return false;
+}
+
+export function tryGitInit(root: string, message: string): boolean {
+ let didInit = false;
+ try {
+ execSync("git --version", { stdio: "ignore" });
+ if (isInGitRepository() || isInMercurialRepository()) {
+ return false;
+ }
+
+ execSync("git init", { stdio: "ignore" });
+ didInit = true;
+
+ execSync("git checkout -b main", { stdio: "ignore" });
+
+ execSync("git add -A", { stdio: "ignore" });
+ execSync(`git commit -m "${message}"`, {
+ stdio: "ignore",
+ });
+ return true;
+ } catch (err) {
+ if (didInit) {
+ try {
+ rimraf.sync(path.join(root, ".git"));
+ } catch (_) {}
+ }
+ return false;
+ }
+}
+
+export function tryGitCommit(message: string): boolean {
+ try {
+ execSync("git add -A", { stdio: "ignore" });
+ execSync(`git commit -m "${message}"`, {
+ stdio: "ignore",
+ });
+ return true;
+ } catch (err) {
+ return false;
+ }
+}
diff --git a/packages/create-turbo/src/utils/isDefaultExample.ts b/packages/create-turbo/src/utils/isDefaultExample.ts
new file mode 100644
index 0000000..9fb2ef2
--- /dev/null
+++ b/packages/create-turbo/src/utils/isDefaultExample.ts
@@ -0,0 +1,5 @@
+export const DEFAULT_EXAMPLES = new Set(["basic", "default"]);
+
+export function isDefaultExample(example: string): boolean {
+ return DEFAULT_EXAMPLES.has(example);
+}
diff --git a/packages/create-turbo/src/utils/isFolderEmpty.ts b/packages/create-turbo/src/utils/isFolderEmpty.ts
new file mode 100644
index 0000000..4de2d58
--- /dev/null
+++ b/packages/create-turbo/src/utils/isFolderEmpty.ts
@@ -0,0 +1,37 @@
+import fs from "fs-extra";
+
+const VALID_FILES = [
+ ".DS_Store",
+ ".git",
+ ".gitattributes",
+ ".gitignore",
+ ".gitlab-ci.yml",
+ ".hg",
+ ".hgcheck",
+ ".hgignore",
+ ".idea",
+ ".npmignore",
+ ".travis.yml",
+ "LICENSE",
+ "Thumbs.db",
+ "docs",
+ "mkdocs.yml",
+ "npm-debug.log",
+ "yarn-debug.log",
+ "yarn-error.log",
+ "yarnrc.yml",
+ ".yarn",
+];
+
+export function isFolderEmpty(root: string): {
+ isEmpty: boolean;
+ conflicts: Array<string>;
+} {
+ const conflicts = fs
+ .readdirSync(root)
+ .filter((file) => !VALID_FILES.includes(file))
+ // Support IntelliJ IDEA-based editors
+ .filter((file) => !/\.iml$/.test(file));
+
+ return { isEmpty: conflicts.length === 0, conflicts };
+}
diff --git a/packages/create-turbo/src/utils/isOnline.ts b/packages/create-turbo/src/utils/isOnline.ts
new file mode 100644
index 0000000..f02b2e6
--- /dev/null
+++ b/packages/create-turbo/src/utils/isOnline.ts
@@ -0,0 +1,40 @@
+import { execSync } from "child_process";
+import dns from "dns";
+import url from "url";
+
+function getProxy(): string | undefined {
+ if (process.env.https_proxy) {
+ return process.env.https_proxy;
+ }
+
+ try {
+ const httpsProxy = execSync("npm config get https-proxy").toString().trim();
+ return httpsProxy !== "null" ? httpsProxy : undefined;
+ } catch (e) {
+ return;
+ }
+}
+
+export function isOnline(): Promise<boolean> {
+ return new Promise((resolve) => {
+ dns.lookup("registry.yarnpkg.com", (registryErr) => {
+ if (!registryErr) {
+ return resolve(true);
+ }
+
+ const proxy = getProxy();
+ if (!proxy) {
+ return resolve(false);
+ }
+
+ const { hostname } = url.parse(proxy);
+ if (!hostname) {
+ return resolve(false);
+ }
+
+ dns.lookup(hostname, (proxyErr) => {
+ resolve(proxyErr == null);
+ });
+ });
+ });
+}
diff --git a/packages/create-turbo/src/utils/isWriteable.ts b/packages/create-turbo/src/utils/isWriteable.ts
new file mode 100644
index 0000000..132c42a
--- /dev/null
+++ b/packages/create-turbo/src/utils/isWriteable.ts
@@ -0,0 +1,10 @@
+import fs from "fs-extra";
+
+export async function isWriteable(directory: string): Promise<boolean> {
+ try {
+ await fs.access(directory, (fs.constants || fs).W_OK);
+ return true;
+ } catch (err) {
+ return false;
+ }
+}
diff --git a/packages/create-turbo/src/utils/notifyUpdate.ts b/packages/create-turbo/src/utils/notifyUpdate.ts
new file mode 100644
index 0000000..e1dadc0
--- /dev/null
+++ b/packages/create-turbo/src/utils/notifyUpdate.ts
@@ -0,0 +1,22 @@
+import chalk from "chalk";
+import checkForUpdate from "update-check";
+
+import cliPkgJson from "../../package.json";
+
+const update = checkForUpdate(cliPkgJson).catch(() => null);
+
+export default async function notifyUpdate(): Promise<void> {
+ try {
+ const res = await update;
+ if (res?.latest) {
+ console.log();
+ console.log(
+ chalk.yellow.bold("A new version of `create-turbo` is available!")
+ );
+ console.log();
+ }
+ process.exit();
+ } catch (_e: any) {
+ // ignore error
+ }
+}