aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/create-turbo
diff options
context:
space:
mode:
Diffstat (limited to 'packages/create-turbo')
-rw-r--r--packages/create-turbo/.gitignore1
-rw-r--r--packages/create-turbo/LICENSE373
-rw-r--r--packages/create-turbo/README.md13
-rw-r--r--packages/create-turbo/__tests__/examples.test.ts134
-rw-r--r--packages/create-turbo/__tests__/git.test.ts239
-rw-r--r--packages/create-turbo/__tests__/index.test.ts90
-rw-r--r--packages/create-turbo/__tests__/isFolderEmpty.test.ts41
-rw-r--r--packages/create-turbo/__tests__/isWritable.test.ts35
-rw-r--r--packages/create-turbo/__tests__/test-utils.ts34
-rw-r--r--packages/create-turbo/jest.config.js11
-rw-r--r--packages/create-turbo/package.json65
-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
-rw-r--r--packages/create-turbo/tsconfig.json7
-rw-r--r--packages/create-turbo/tsup.config.ts9
-rw-r--r--packages/create-turbo/turbo.json12
34 files changed, 2261 insertions, 0 deletions
diff --git a/packages/create-turbo/.gitignore b/packages/create-turbo/.gitignore
new file mode 100644
index 0000000..47f732d
--- /dev/null
+++ b/packages/create-turbo/.gitignore
@@ -0,0 +1 @@
+!templates/*/.npmrc
diff --git a/packages/create-turbo/LICENSE b/packages/create-turbo/LICENSE
new file mode 100644
index 0000000..fa0086a
--- /dev/null
+++ b/packages/create-turbo/LICENSE
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0. \ No newline at end of file
diff --git a/packages/create-turbo/README.md b/packages/create-turbo/README.md
new file mode 100644
index 0000000..485485f
--- /dev/null
+++ b/packages/create-turbo/README.md
@@ -0,0 +1,13 @@
+# Welcome to Turborepo
+
+[Turborepo](https://turbo.build/repo) is a high-performance monorepo build-system for modern JavaScript and TypeScript codebases.
+
+To get started, open a new shell and run:
+
+```sh
+npx create-turbo@latest
+```
+
+Then follow the prompts you see in your terminal.
+
+For more information about Turborepo, [visit turbo.build/repo](https://turbo.build/repo) and follow us on Twitter ([@turborepo](https://twitter.com/turborepo))!
diff --git a/packages/create-turbo/__tests__/examples.test.ts b/packages/create-turbo/__tests__/examples.test.ts
new file mode 100644
index 0000000..20d4464
--- /dev/null
+++ b/packages/create-turbo/__tests__/examples.test.ts
@@ -0,0 +1,134 @@
+import got from "got";
+import * as Got from "got";
+import { isUrlOk, getRepoInfo, hasRepo } from "../src/utils/examples";
+
+jest.mock("got", () => ({
+ __esModule: true,
+ ...jest.requireActual("got"),
+}));
+
+describe("examples", () => {
+ describe("isUrlOk", () => {
+ it("returns true if url returns 200", async () => {
+ const mockGot = jest
+ .spyOn(got, "head")
+ .mockReturnValue({ statusCode: 200 } as any);
+
+ const url = "https://github.com/vercel/turbo/";
+ const result = await isUrlOk(url);
+ expect(result).toBe(true);
+
+ expect(mockGot).toHaveBeenCalledWith(url);
+ mockGot.mockRestore();
+ });
+
+ it("returns false if url returns status != 200", async () => {
+ const mockGot = jest
+ .spyOn(got, "head")
+ .mockReturnValue({ statusCode: 401 } as any);
+
+ const url = "https://not-github.com/vercel/turbo/";
+ const result = await isUrlOk(url);
+ expect(result).toBe(false);
+
+ expect(mockGot).toHaveBeenCalledWith(url);
+ mockGot.mockRestore();
+ });
+ });
+
+ describe("getRepoInfo", () => {
+ test.each([
+ {
+ repoUrl: "https://github.com/vercel/turbo/",
+ examplePath: undefined,
+ defaultBranch: "main",
+ expectBranchLookup: true,
+ expected: {
+ username: "vercel",
+ name: "turbo",
+ branch: "main",
+ filePath: "",
+ },
+ },
+ {
+ repoUrl:
+ "https://github.com/vercel/turbo/tree/canary/examples/kitchen-sink",
+ examplePath: undefined,
+ defaultBranch: "canary",
+ expectBranchLookup: false,
+ expected: {
+ username: "vercel",
+ name: "turbo",
+ branch: "canary",
+ filePath: "examples/kitchen-sink",
+ },
+ },
+ {
+ repoUrl: "https://github.com/vercel/turbo/tree/tek/test-branch/",
+ examplePath: "examples/basic",
+ defaultBranch: "canary",
+ expectBranchLookup: false,
+ expected: {
+ username: "vercel",
+ name: "turbo",
+ branch: "tek/test-branch",
+ filePath: "examples/basic",
+ },
+ },
+ ])(
+ "retrieves repo info for $repoUrl and $examplePath",
+ async ({
+ repoUrl,
+ examplePath,
+ defaultBranch,
+ expectBranchLookup,
+ expected,
+ }) => {
+ const mockGot = jest.spyOn(Got, "default").mockReturnValue({
+ body: JSON.stringify({ default_branch: defaultBranch }),
+ } as any);
+
+ const url = new URL(repoUrl);
+ const result = await getRepoInfo(url, examplePath);
+ expect(result).toMatchObject(expected);
+
+ if (result && expectBranchLookup) {
+ expect(mockGot).toHaveBeenCalledWith(
+ `https://api.github.com/repos/${result.username}/${result.name}`
+ );
+ }
+
+ mockGot.mockRestore();
+ }
+ );
+ });
+
+ describe("hasRepo", () => {
+ test.each([
+ {
+ repoInfo: {
+ username: "vercel",
+ name: "turbo",
+ branch: "main",
+ filePath: "",
+ },
+ expected: true,
+ expectedUrl:
+ "https://api.github.com/repos/vercel/turbo/contents/package.json?ref=main",
+ },
+ ])(
+ "checks repo at $expectedUrl",
+ async ({ expected, repoInfo, expectedUrl }) => {
+ const mockGot = jest
+ .spyOn(got, "head")
+ .mockReturnValue({ statusCode: 200 } as any);
+
+ const result = await hasRepo(repoInfo);
+ expect(result).toBe(expected);
+
+ expect(mockGot).toHaveBeenCalledWith(expectedUrl);
+ mockGot.mockRestore();
+ }
+ );
+ });
+});
diff --git a/packages/create-turbo/__tests__/git.test.ts b/packages/create-turbo/__tests__/git.test.ts
new file mode 100644
index 0000000..27ac118
--- /dev/null
+++ b/packages/create-turbo/__tests__/git.test.ts
@@ -0,0 +1,239 @@
+import path from "path";
+import {
+ DEFAULT_IGNORE,
+ GIT_REPO_COMMAND,
+ HG_REPO_COMMAND,
+ isInGitRepository,
+ isInMercurialRepository,
+ tryGitInit,
+} from "../src/utils/git";
+import childProcess from "child_process";
+import { setupTestFixtures } from "@turbo/test-utils";
+
+describe("git", () => {
+ // just to make sure this doesn't get lost
+ it("default .gitignore includes .turbo", async () => {
+ expect(DEFAULT_IGNORE).toContain(".turbo");
+ });
+
+ describe("isInGitRepository", () => {
+ it("returns true when in a repo", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockReturnValue("true");
+
+ const result = isInGitRepository();
+ expect(result).toBe(true);
+
+ expect(mockExecSync).toHaveBeenCalledWith(GIT_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("returns false when not in a repo", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementation(() => {
+ throw new Error(
+ "fatal: not a git repository (or any of the parent directories): .git"
+ );
+ });
+
+ const result = isInGitRepository();
+ expect(result).toBe(false);
+
+ expect(mockExecSync).toHaveBeenCalledWith(GIT_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("returns false on error", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementation(() => {
+ throw new Error("unknown error");
+ });
+
+ const result = isInGitRepository();
+ expect(result).toBe(false);
+
+ expect(mockExecSync).toHaveBeenCalledWith(GIT_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+ });
+
+ describe("isInMercurialRepository", () => {
+ it("returns true when in a repo", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockReturnValue("true");
+
+ const result = isInMercurialRepository();
+ expect(result).toBe(true);
+
+ expect(mockExecSync).toHaveBeenCalledWith(HG_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("returns false when not in a repo", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementation(() => {
+ throw new Error("abort: no repository found (.hg not found)");
+ });
+
+ const result = isInMercurialRepository();
+ expect(result).toBe(false);
+
+ expect(mockExecSync).toHaveBeenCalledWith(HG_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("returns false on error", async () => {
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementation(() => {
+ throw new Error("unknown error");
+ });
+
+ const result = isInMercurialRepository();
+ expect(result).toBe(false);
+
+ expect(mockExecSync).toHaveBeenCalledWith(HG_REPO_COMMAND, {
+ stdio: "ignore",
+ });
+ mockExecSync.mockRestore();
+ });
+ });
+
+ describe("tryGitInit", () => {
+ const { useFixture } = setupTestFixtures({
+ directory: path.join(__dirname, "../"),
+ });
+
+ it("inits a repo succesfully", async () => {
+ const { root } = useFixture({ fixture: `git` });
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockReturnValueOnce("git version 2.38.1")
+ .mockImplementationOnce(() => {
+ throw new Error(
+ "fatal: not a git repository (or any of the parent directories): .git"
+ );
+ })
+ .mockImplementationOnce(() => {
+ throw new Error("abort: no repository found (.hg not found)");
+ })
+ .mockReturnValue("success");
+
+ const result = tryGitInit(root, "test commit");
+ expect(result).toBe(true);
+
+ const calls = [
+ "git --version",
+ "git init",
+ "git checkout -b main",
+ "git add -A",
+ 'git commit -m "test commit"',
+ ];
+ expect(mockExecSync).toHaveBeenCalledTimes(calls.length + 2);
+ calls.forEach((call) => {
+ expect(mockExecSync).toHaveBeenCalledWith(call, {
+ stdio: "ignore",
+ });
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("skips init if already in a repo", async () => {
+ const { root } = useFixture({ fixture: `git` });
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockReturnValueOnce("git version 2.38.1")
+ .mockReturnValueOnce("true")
+ .mockReturnValue("success");
+
+ const result = tryGitInit(root, "test commit");
+ expect(result).toBe(false);
+
+ const calls = ["git --version"];
+
+ // 1 call for git --version, 1 call for isInGitRepository
+ expect(mockExecSync).toHaveBeenCalledTimes(calls.length + 1);
+ calls.forEach((call) => {
+ expect(mockExecSync).toHaveBeenCalledWith(call, {
+ stdio: "ignore",
+ });
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("returns false on unexpected error", async () => {
+ const { root } = useFixture({ fixture: `git` });
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementationOnce(() => {
+ throw new Error("fatal: unknown command git");
+ });
+
+ const result = tryGitInit(root, "test commit");
+ expect(result).toBe(false);
+
+ const calls = ["git --version"];
+
+ expect(mockExecSync).toHaveBeenCalledTimes(calls.length);
+ calls.forEach((call) => {
+ expect(mockExecSync).toHaveBeenCalledWith(call, {
+ stdio: "ignore",
+ });
+ });
+ mockExecSync.mockRestore();
+ });
+
+ it("cleans up from partial init on failure", async () => {
+ const { root } = useFixture({ fixture: `git` });
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockReturnValueOnce("git version 2.38.1")
+ .mockImplementationOnce(() => {
+ throw new Error(
+ "fatal: not a git repository (or any of the parent directories): .git"
+ );
+ })
+ .mockImplementationOnce(() => {
+ throw new Error("abort: no repository found (.hg not found)");
+ })
+ .mockReturnValueOnce("success")
+ .mockReturnValueOnce("success")
+ .mockImplementationOnce(() => {
+ throw new Error("fatal: could not add files");
+ });
+
+ const result = tryGitInit(root, "test commit");
+ expect(result).toBe(false);
+
+ const calls = [
+ "git --version",
+ "git init",
+ "git checkout -b main",
+ "git add -A",
+ ];
+
+ expect(mockExecSync).toHaveBeenCalledTimes(calls.length + 2);
+ calls.forEach((call) => {
+ expect(mockExecSync).toHaveBeenCalledWith(call, {
+ stdio: "ignore",
+ });
+ });
+ mockExecSync.mockRestore();
+ });
+ });
+});
diff --git a/packages/create-turbo/__tests__/index.test.ts b/packages/create-turbo/__tests__/index.test.ts
new file mode 100644
index 0000000..641b193
--- /dev/null
+++ b/packages/create-turbo/__tests__/index.test.ts
@@ -0,0 +1,90 @@
+import path from "path";
+import chalk from "chalk";
+import childProcess from "child_process";
+import { setupTestFixtures, spyConsole } from "@turbo/test-utils";
+import { create } from "../src/commands/create";
+import type { CreateCommandArgument } from "../src/commands/create/types";
+import { turboGradient } from "../src/logger";
+import type { PackageManager } from "@turbo/workspaces";
+
+// imports for mocks
+import * as createProject from "../src/commands/create/createProject";
+import * as turboWorkspaces from "@turbo/workspaces";
+import { getWorkspaceDetailsMockReturnValue } from "./test-utils";
+
+jest.mock("@turbo/workspaces", () => ({
+ __esModule: true,
+ ...jest.requireActual("@turbo/workspaces"),
+}));
+
+describe("create-turbo", () => {
+ const { useFixture } = setupTestFixtures({
+ directory: path.join(__dirname, "../"),
+ });
+
+ const mockConsole = spyConsole();
+
+ test.each<{ packageManager: PackageManager }>([
+ { packageManager: "yarn" },
+ { packageManager: "npm" },
+ { packageManager: "pnpm" },
+ ])(
+ "outputs expected console messages when using $packageManager",
+ async ({ packageManager }) => {
+ const { root } = useFixture({ fixture: `create-turbo` });
+
+ const availableScripts = ["build", "test", "dev"];
+
+ const mockCreateProject = jest
+ .spyOn(createProject, "createProject")
+ .mockResolvedValue({
+ cdPath: "",
+ hasPackageJson: true,
+ availableScripts,
+ });
+
+ const mockGetWorkspaceDetails = jest
+ .spyOn(turboWorkspaces, "getWorkspaceDetails")
+ .mockResolvedValue(
+ getWorkspaceDetailsMockReturnValue({
+ root,
+ packageManager,
+ })
+ );
+
+ const mockExecSync = jest
+ .spyOn(childProcess, "execSync")
+ .mockImplementation(() => {
+ return "success";
+ });
+
+ await create(
+ root as CreateCommandArgument,
+ packageManager as CreateCommandArgument,
+ {
+ skipInstall: true,
+ example: "default",
+ }
+ );
+
+ const expected = `${chalk.bold(
+ turboGradient(">>> Success!")
+ )} Created a new Turborepo at "${path.relative(process.cwd(), root)}".`;
+
+ expect(mockConsole.log).toHaveBeenCalledWith(expected);
+ expect(mockConsole.log).toHaveBeenCalledWith(
+ "Inside that directory, you can run several commands:"
+ );
+
+ availableScripts.forEach((script) => {
+ expect(mockConsole.log).toHaveBeenCalledWith(
+ chalk.cyan(` ${packageManager} run ${script}`)
+ );
+ });
+
+ mockCreateProject.mockRestore();
+ mockGetWorkspaceDetails.mockRestore();
+ mockExecSync.mockRestore();
+ }
+ );
+});
diff --git a/packages/create-turbo/__tests__/isFolderEmpty.test.ts b/packages/create-turbo/__tests__/isFolderEmpty.test.ts
new file mode 100644
index 0000000..66b2310
--- /dev/null
+++ b/packages/create-turbo/__tests__/isFolderEmpty.test.ts
@@ -0,0 +1,41 @@
+import fs from "fs-extra";
+import path from "path";
+import { isFolderEmpty } from "../src/utils/isFolderEmpty";
+import { setupTestFixtures } from "@turbo/test-utils";
+
+describe("isFolderEmpty", () => {
+ const { useFixture } = setupTestFixtures({
+ directory: path.join(__dirname, "../"),
+ });
+
+ it("correctly identifies an empty directory", async () => {
+ const { root } = useFixture({ fixture: `is-folder-empty` });
+ const result = isFolderEmpty(root);
+ expect(result.isEmpty).toEqual(true);
+ expect(result.conflicts).toEqual([]);
+ });
+
+ it("correctly identifies a directory with non-conflicting files", async () => {
+ const { root } = useFixture({ fixture: `is-folder-empty` });
+ fs.writeFileSync(path.join(root, "LICENSE"), "MIT");
+ const result = isFolderEmpty(root);
+ expect(result.isEmpty).toEqual(true);
+ expect(result.conflicts).toEqual([]);
+ });
+
+ it("correctly identifies a directory non-conflicting files (intelliJ)", async () => {
+ const { root } = useFixture({ fixture: `is-folder-empty` });
+ fs.writeFileSync(path.join(root, "intellij-idea-config.iml"), "{}");
+ const result = isFolderEmpty(root);
+ expect(result.isEmpty).toEqual(true);
+ expect(result.conflicts).toEqual([]);
+ });
+
+ it("correctly identifies a directory conflicting files", async () => {
+ const { root } = useFixture({ fixture: `is-folder-empty` });
+ fs.writeFileSync(path.join(root, "README.md"), "my cool project");
+ const result = isFolderEmpty(root);
+ expect(result.isEmpty).toEqual(false);
+ expect(result.conflicts).toEqual(["README.md"]);
+ });
+});
diff --git a/packages/create-turbo/__tests__/isWritable.test.ts b/packages/create-turbo/__tests__/isWritable.test.ts
new file mode 100644
index 0000000..b06670b
--- /dev/null
+++ b/packages/create-turbo/__tests__/isWritable.test.ts
@@ -0,0 +1,35 @@
+import path from "path";
+import { isWriteable } from "../src/utils/isWriteable";
+import { setupTestFixtures } from "@turbo/test-utils";
+import fs from "fs-extra";
+
+describe("isWriteable", () => {
+ const { useFixture } = setupTestFixtures({
+ directory: path.join(__dirname, "../"),
+ });
+
+ it("correctly identifies a writeable directory", async () => {
+ const { root } = useFixture({ fixture: `is-writeable` });
+ const result = await isWriteable(root);
+ expect(result).toEqual(true);
+ });
+
+ it("correctly identifies a non-writeable directory", async () => {
+ const { root } = useFixture({ fixture: `is-writeable` });
+ const result = await isWriteable(path.join(root, "does-not-exist"));
+ expect(result).toEqual(false);
+ });
+
+ it("returns false on unexpected failure", async () => {
+ const { root } = useFixture({ fixture: `is-writeable` });
+ const mockFsAccess = jest
+ .spyOn(fs, "access")
+ .mockRejectedValue(new Error("unknown error"));
+
+ const result = await isWriteable(root);
+ expect(result).toEqual(false);
+ expect(mockFsAccess).toHaveBeenCalledWith(root, fs.constants.W_OK);
+
+ mockFsAccess.mockRestore();
+ });
+});
diff --git a/packages/create-turbo/__tests__/test-utils.ts b/packages/create-turbo/__tests__/test-utils.ts
new file mode 100644
index 0000000..fa6c204
--- /dev/null
+++ b/packages/create-turbo/__tests__/test-utils.ts
@@ -0,0 +1,34 @@
+import path from "path";
+import { PackageManager } from "@turbo/workspaces";
+
+export function getWorkspaceDetailsMockReturnValue({
+ root,
+ packageManager = "npm",
+}: {
+ root: string;
+ packageManager: PackageManager;
+}) {
+ return {
+ name: "mock-project",
+ packageManager,
+ paths: {
+ root,
+ packageJson: path.join(root, "package.json"),
+ lockfile: path.join(root, "yarn.lock"),
+ nodeModules: path.join(root, "node_modules"),
+ },
+ workspaceData: {
+ globs: ["packages/*"],
+ workspaces: [
+ {
+ name: "packages/mock-package",
+ paths: {
+ root: path.join(root, "packages/mock-package"),
+ packageJson: path.join(root, "packages/mock-package/package.json"),
+ nodeModules: path.join(root, "packages/mock-package/node_modules"),
+ },
+ },
+ ],
+ },
+ };
+}
diff --git a/packages/create-turbo/jest.config.js b/packages/create-turbo/jest.config.js
new file mode 100644
index 0000000..b738f4b
--- /dev/null
+++ b/packages/create-turbo/jest.config.js
@@ -0,0 +1,11 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+ preset: "ts-jest/presets/js-with-ts",
+ testEnvironment: "node",
+ testPathIgnorePatterns: ["/__fixtures__/", "/__tests__/test-utils.ts"],
+ coveragePathIgnorePatterns: ["/__fixtures__/", "/__tests__/test-utils.ts"],
+ transformIgnorePatterns: ["/node_modules/(?!(ansi-regex)/)"],
+ modulePathIgnorePatterns: ["<rootDir>/node_modules", "<rootDir>/dist"],
+ collectCoverage: true,
+ verbose: true,
+};
diff --git a/packages/create-turbo/package.json b/packages/create-turbo/package.json
new file mode 100644
index 0000000..9f723ba
--- /dev/null
+++ b/packages/create-turbo/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "create-turbo",
+ "version": "1.9.4-canary.2",
+ "description": "Create a new Turborepo",
+ "homepage": "https://turbo.build/repo",
+ "license": "MPL-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vercel/turbo",
+ "directory": "packages/create-turbo"
+ },
+ "bugs": {
+ "url": "https://github.com/vercel/turbo/issues"
+ },
+ "bin": {
+ "create-turbo": "dist/cli.js"
+ },
+ "scripts": {
+ "build": "tsup",
+ "test": "jest",
+ "lint": "eslint src/**/*.ts",
+ "check-types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "async-retry": "^1.3.3",
+ "chalk": "2.4.2",
+ "commander": "^10.0.0",
+ "cross-spawn": "^7.0.3",
+ "execa": "5.1.1",
+ "fs-extra": "^10.1.0",
+ "got": "^11.8.5",
+ "gradient-string": "^2.0.0",
+ "inquirer": "^8.0.0",
+ "ora": "4.1.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.8",
+ "tar": "6.1.13",
+ "update-check": "^1.5.4"
+ },
+ "devDependencies": {
+ "@turbo/workspaces": "workspace:*",
+ "@types/async-retry": "^1.4.5",
+ "@types/cross-spawn": "^6.0.2",
+ "@types/fs-extra": "^9.0.13",
+ "@types/gradient-string": "^1.1.2",
+ "@types/inquirer": "^7.3.1",
+ "@types/jest": "^27.4.0",
+ "@types/node": "^16.11.12",
+ "@types/rimraf": "^3.0.2",
+ "@types/semver": "^7.3.9",
+ "@types/tar": "^6.1.4",
+ "eslint": "^7.23.0",
+ "jest": "^27.4.3",
+ "strip-ansi": "^6.0.1",
+ "ts-jest": "^27.1.1",
+ "@turbo/tsconfig": "workspace:*",
+ "tsup": "^5.10.3",
+ "@turbo/utils": "workspace:*",
+ "@turbo/test-utils": "workspace:*",
+ "typescript": "^4.5.5"
+ },
+ "files": [
+ "dist"
+ ]
+}
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
+ }
+}
diff --git a/packages/create-turbo/tsconfig.json b/packages/create-turbo/tsconfig.json
new file mode 100644
index 0000000..abcb2c6
--- /dev/null
+++ b/packages/create-turbo/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "@turbo/tsconfig/library.json",
+ "exclude": ["templates"],
+ "compilerOptions": {
+ "rootDir": "."
+ }
+}
diff --git a/packages/create-turbo/tsup.config.ts b/packages/create-turbo/tsup.config.ts
new file mode 100644
index 0000000..18b0666
--- /dev/null
+++ b/packages/create-turbo/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig, Options } from "tsup";
+
+export default defineConfig((options: Options) => ({
+ entry: ["src/cli.ts"],
+ format: ["cjs"],
+ clean: true,
+ minify: true,
+ ...options,
+}));
diff --git a/packages/create-turbo/turbo.json b/packages/create-turbo/turbo.json
new file mode 100644
index 0000000..6466b2d
--- /dev/null
+++ b/packages/create-turbo/turbo.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "../../docs/public/schema.json",
+ "extends": ["//"],
+ "pipeline": {
+ "test": {
+ "dependsOn": ["build"]
+ },
+ "build": {
+ "dependsOn": ["^build"]
+ }
+ }
+}