diff --git a/.changeset/create-remix.md b/.changeset/create-remix.md new file mode 100644 index 00000000000..6b2cbd8f09a --- /dev/null +++ b/.changeset/create-remix.md @@ -0,0 +1,8 @@ +--- +"create-remix": major +"@remix-run/dev": major +--- + +The `create-remix` CLI has been rewritten to feature a cleaner interface, Git repo initialization and optional `remix.init` script execution. The interactive template prompt and official Remix stack/template shorthands have also been removed so that community/third-party templates are now on a more equal footing. + +The code for `create-remix` has been moved out of the Remix CLI since it's not intended for use within an existing Remix application. This means that the `remix create` command is no longer available. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 29a2b9927ac..86c916b9bee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,7 +35,7 @@ Or > I opened up my windows machine and ran this script: > > ``` -> npx create-remix@0.0.0-experimental-7e420ee3 --template remix my-test +> npx create-remix@0.0.0-experimental-7e420ee3 my-test > cd my-test > npm run dev > ``` diff --git a/.github/workflows/stacks.yml b/.github/workflows/stacks.yml index dc193349238..2cdd2804acc 100644 --- a/.github/workflows/stacks.yml +++ b/.github/workflows/stacks.yml @@ -33,7 +33,7 @@ jobs: - name: ⚒️ Create new ${{ matrix.stack.name }} app with ${{ inputs.version }} run: | - npx -y create-remix@${{ inputs.version }} ${{ matrix.stack.name }} --template ${{ matrix.stack.repo }} --typescript --no-install + npx -y create-remix@${{ inputs.version }} ${{ matrix.stack.name }} --template ${{ matrix.stack.repo }} --no-install --no-git-init - name: ⎔ Setup dependency caching uses: actions/setup-node@v3 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 2c53f359f73..20b84064f41 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -5,19 +5,12 @@ toc: false # Deployment -Remix maintains a few starter templates to help you deploy to various servers right from the start. You should be able to initialize your app and get it live within a couple of minutes. +Remix maintains a few [starter templates][starter-templates] to help you deploy to various servers right from the start. You should be able to initialize your app and get it live within a couple of minutes. -Running `npx create-remix@latest` will prompt you to pick a deployment target: +Running `npx create-remix@latest` with the `--template` flag allows you to provide the URL to one of these templates, for example: ```sh -npx create-remix@latest -? Where do you want to deploy? (Use arrow keys) -❯ Remix App Server - Architect - Cloudflare Workers - Fly.io - Netlify - Vercel +npx create-remix@latest --template remix-run/remix/templates/express ``` Each target has unique file structures, configuration files, cli commands that need to be run, server environment variables to be set etc. Because of this, it's important to read the README.md to deploy the app. It's got all of the steps you need to take to get your app live within minutes. @@ -27,3 +20,5 @@ Each target has unique file structures, configuration files, cli commands that n Additionally, Remix doesn't abstract over your infrastructure, so the templates don't hide anything about where you're deploying to (you may want other functions besides the Remix app!). You're welcome to tweak the configuration to suit your needs. Remix runs on your server, but it is not your server. In a nutshell: if you want to deploy your app, Read the manual 😋 + +[starter-templates]: https://github.com/remix-run/remix/tree/main/templates diff --git a/docs/other-api/create-remix.md b/docs/other-api/create-remix.md new file mode 100644 index 00000000000..7c1fbe54194 --- /dev/null +++ b/docs/other-api/create-remix.md @@ -0,0 +1,73 @@ +--- +title: "create-remix (CLI)" +--- + +# `create-remix` + +The `create-remix` CLI will create a new Remix project. Without passing arguments, this command will launch an interactive CLI to configure the new project and set it up in a given directory. + +```sh +npx create-remix@latest +``` + +Optionally you can pass the desired directory path as an argument and a starter template with the `--template` flag. + +```sh +npx create-remix@latest +``` + +To get a full list of available commands and flags, run: + +```sh +npx create-remix@latest --help +``` + +### Package managers + +`create-remix` can also be invoked using the `create` command of various package managers, allowing you to choose between npm, Yarn and pnpm for managing the install process. + +```sh +npm create remix@latest +# or +yarn create remix@latest +# or +pnpm create remix@latest +``` + +### `create-remix --template` + +For a more comprehensive guide to available templates, see our [templates page.][templates] + +A valid template can be: + +- a GitHub repo shorthand — `:username/:repo` or `:username/:repo/:directory` +- the URL of a GitHub repo (or directory within it) — `https://github.com/:username/:repo` or `https://github.com/:username/:repo/tree/:branch/:directory` +- the URL of a remote tarball — `https://example.com/remix-template.tar.gz` +- a local file path to a directory of files — `./path/to/remix-template` +- a local file path to a tarball — `./path/to/remix-template.tar.gz` + +```sh +npx create-remix@latest ./my-app --template remix-run/grunge-stack +npx create-remix@latest ./my-app --template remix-run/remix/templates/remix +npx create-remix@latest ./my-app --template remix-run/examples/basic +npx create-remix@latest ./my-app --template :username/:repo +npx create-remix@latest ./my-app --template :username/:repo/:directory +npx create-remix@latest ./my-app --template https://github.com/:username/:repo +npx create-remix@latest ./my-app --template https://github.com/:username/:repo/tree/:branch +npx create-remix@latest ./my-app --template https://github.com/:username/:repo/tree/:branch/:directory +npx create-remix@latest ./my-app --template https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz +npx create-remix@latest ./my-app --template https://github.com/:username/:repo/releases/latest/download/:tag.tar.gz +npx create-remix@latest ./my-app --template https://example.com/remix-template.tar.gz +npx create-remix@latest ./my-app --template ./path/to/remix-template +npx create-remix@latest ./my-app --template ./path/to/remix-template.tar.gz +``` + + + +[templates]: ../pages/templates diff --git a/docs/other-api/dev.md b/docs/other-api/dev.md index 14f9edb54b3..853dc2ffa8c 100644 --- a/docs/other-api/dev.md +++ b/docs/other-api/dev.md @@ -21,45 +21,6 @@ To get a full list of available commands and flags, run: npx @remix-run/dev -h ``` -## `remix create` - -`remix create` will create a new Remix project. Without passing arguments, this command will launch an interactive CLI to configure the new project and set it up in a given directory. Optionally you can pass the desired directory path as an argument and a starter template with the `--template` flag. - -```sh -remix create -``` - -### `remix create --template` - -A valid template can be: - -- a directory located in the [`templates` folder of the Remix repository][templates-folder-of-the-remix-repository] -- a local file path to a directory of files -- a local file path to a tarball -- the name of a `:username/:repo` on GitHub -- the URL of a remote tarball - -```sh -remix create ./my-app --template fly -remix create ./my-app --template /path/to/remix-template -remix create ./my-app --template /path/to/remix-template.tar.gz -remix create ./my-app --template remix-run/grunge-stack -remix create ./my-app --template :username/:repo -remix create ./my-app --template https://github.com/:username/:repo -remix create ./my-app --template https://github.com/:username/:repo/tree/:branch -remix create ./my-app --template https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz -remix create ./my-app --template https://github.com/:username/:repo/releases/latest/download/:tag.tar.gz -remix create ./my-app --template https://example.com/remix-template.tar.gz -``` - - - ## `remix build` Builds your app for production. This command will set `process.env.NODE_ENV` to `production` and minify the output for deployment. @@ -112,7 +73,7 @@ You could even use `remix.init/index.js` to ask further questions to the develop After the init script has been run, the `remix.init` folder gets deleted, so you don't need to worry about it cluttering up the finished codebase. -You'll only ever interact with this command if you've opted out of installing dependencies when creating a new Remix app, or you're developing a custom template that includes a `remix.init/index.js` file. +You'll only ever interact with this command if you've opted out of installing dependencies or running the `remix.init` script when creating a new Remix app, or you're developing a custom template that includes a `remix.init/index.js` file. ### `remix init --no-delete` diff --git a/docs/pages/stacks.md b/docs/pages/stacks.md index 6812aa6e1ce..b16ce0ffc79 100644 --- a/docs/pages/stacks.md +++ b/docs/pages/stacks.md @@ -1,95 +1,11 @@ --- title: Remix Stacks -description: The quickest way to get rocking and rolling with Remix -order: 3 +toc: false +hidden: true --- # Remix Stacks -Remix Stacks is a feature of the Remix CLI that allows you to generate a Remix project quickly and easily. There are several built-in and official stacks that are full-blown applications. You can also make your own (read more below). +[The documentation for Remix Stacks has moved to the "Templates" page.][moved] -[Read the feature announcement blog post][read-the-feature-announcement-blog-post] and [watch Remix Stacks videos on YouTube][watch-remix-stacks-videos-on-you-tube]. - -The built-in official stacks come ready with common things you need for a production application including: - -- Database -- Automatic deployment pipelines -- Authentication -- Testing -- Linting/Formatting/TypeScript - -What you're left with is everything completely set up for you to just get to work building whatever amazing web experience you want to build with Remix. Here are the built-in official stacks: - -- [The Blues Stack][the-blues-stack]: Deployed to the edge (distributed) with a long-running Node.js server and PostgreSQL database. Intended for large and fast production-grade applications serving millions of users. -- [The Indie Stack][the-indie-stack]: Deployed to a long-running Node.js server with a persistent SQLite database. This stack is great for websites with dynamic data that you control (blogs, marketing, content sites). It's also a perfect, low-complexity bootstrap for MVPs, prototypes, and proof-of-concepts that can later be updated to the Blues stack easily. -- [The Grunge Stack][the-grunge-stack]: Deployed to a serverless function running Node.js with DynamoDB for persistence. Intended for folks who want to deploy a production-grade application on AWS infrastructure serving millions of users. - -Yes, these are named after music genres. 🤘 Rock on. - -There will be more stacks available in the future. And you can make your own (and we strongly encourage it)! - -## Custom Stacks - -The Remix CLI will help you get started with one of these built-in stacks, but if you want, you can create your own stack and the Remix CLI will help you get started with that stack. There are several ways to do this, but the most straightforward is to create a GitHub repo: - -``` -npx create-remix@latest --template my-username/my-repo -``` - -Custom stacks give an enormous amount of power and flexibility, and we hope you create your own that suits the preferences of you and your organization (feel free to fork ours!). - -Yes, we do recommend that you name your own stack after a music sub-genre (not "rock" but "indie"!). In the future, we will have a page where you can list your open-source stacks for others to learn and discover. For now, please add the remix-stack tag to your repo! - -### `--template` - -The template option can be any of the following values: - -- The name of a stack in the remix-run GH org (e.g. `blues-stack`) -- A GH username/repo combo (e.g. `mcansh/snkrs`) -- A file path to a directory on disk (e.g. `/my/remix-stack`) -- A path to a tarball on disk (e.g. `/my/remix-stack.tar.gz`) -- A URL to a tarball (e.g. `https://example.com/remix-stack.tar.gz`) -- A file URL (e.g. `file:///Users/michael/remix-stack.tar.gz`) - -Additionally, if your stack is in a private GitHub repo, you can pass a GitHub token via the `--token` cli flag: - -``` -npx create-remix@latest --template your-private/repo --token yourtoken -``` - -The [token just needs `repo` access][repo access token]. - -### Custom Template Tips - -#### Dependency versions - -If you set any dependencies in package.json to `*`, the Remix CLI will change it to a semver caret of the installed Remix version: - -```diff -- "remix": "*", -+ "remix": "^1.2.3", -``` - -This allows you to not have to regularly update your template to the latest version of that specific package. Of course, you do not have to put `*` if you'd prefer to manually manage the version for that package. - -#### Customize Initialization - -If the template has a `remix.init/index.js` file at the root then that file will be executed after the project has been generated and dependencies have been installed. This gives you a chance to do anything you'd like as part of the initialization of your template. For example, in the blues stack, the `app` property has to be globally unique, so we use the `remix.init/index.js` file to change it to the name of the directory that was created for the project + a couple random characters. - -You could even use `remix.init/index.js` to ask further questions to the developer for additional configuration (using something like [inquirer][inquirer]). Sometimes, you'll need dependencies installed to do this, but those deps are only useful during initialization. In that case, you can also create a `remix.init/package.json` with dependencies and the Remix CLI will install those before running your script. - -After the init script has been run, the `remix.init` folder gets deleted, so you don't need to worry about it cluttering up the finished codebase. - -Do note however, that opting out of installing dependencies will not run the remix.init setup, to do so manually, run `remix init`. - -#### Remove TypeScript - -If there's a `tsconfig.json` file in the root of the project, the Remix CLI will ask whether the user wants the TypeScript automatically removed from the template. We don't recommend this, but some folks just really want to write regular JavaScript. - -[repo access token]: https://github.com/settings/tokens/new?description=Remix%20Private%20Stack%20Access&scopes=repo -[inquirer]: https://npm.im/inquirer -[read-the-feature-announcement-blog-post]: /blog/remix-stacks -[watch-remix-stacks-videos-on-you-tube]: https://www.youtube.com/playlist?list=PLXoynULbYuEC8-gJCqyXo94RufAvSA6R3 -[the-blues-stack]: https://github.com/remix-run/blues-stack -[the-indie-stack]: https://github.com/remix-run/indie-stack -[the-grunge-stack]: https://github.com/remix-run/grunge-stack +[moved]: ./templates diff --git a/docs/pages/templates.md b/docs/pages/templates.md new file mode 100644 index 00000000000..23bf92ae15d --- /dev/null +++ b/docs/pages/templates.md @@ -0,0 +1,138 @@ +--- +title: Templates +description: The quickest way to get rocking and rolling with Remix +order: 3 +--- + +# Templates + +When using [`create-remix`][create-remix] to generate a new project, you can choose a template to quickly get up and running. + +## Basic Template + +If you run `create-remix` without providing the `--template` option, you'll get a basic template using the [Remix App Server][remix-app-server]. + +```sh +npx create-remix@latest +``` + +If you are not interested in using TypeScript, you can install the simpler Javascript template instead: + +```sh +npx create-remix --template remix-run/remix/templates/remix-javascript +``` + +This is a great place to start if you're just looking to try out Remix for the first time. You can always extend this starting point yourself or migrate to a more advanced template later. + +## Stacks + +When a template is closer to being a production-ready application, to the point that it provides opinions about the CI/CD pipeline, database and hosting platform, the Remix community refers to these templates as "stacks". + +There are several official stacks provided but you can also make your own (read more below). + +[Read the feature announcement blog post][read-the-feature-announcement-blog-post] and [watch Remix Stacks videos on YouTube][watch-remix-stacks-videos-on-you-tube]. + +### Official Stacks + +The official stacks come ready with common things you need for a production application including: + +- Database +- Automatic deployment pipelines +- Authentication +- Testing +- Linting/Formatting/TypeScript + +What you're left with is everything completely set up for you to just get to work building whatever amazing web experience you want to build with Remix. Here are the official stacks: + +- [The Blues Stack][the-blues-stack]: Deployed to the edge (distributed) with a long-running Node.js server and PostgreSQL database. Intended for large and fast production-grade applications serving millions of users. +- [The Indie Stack][the-indie-stack]: Deployed to a long-running Node.js server with a persistent SQLite database. This stack is great for websites with dynamic data that you control (blogs, marketing, content sites). It's also a perfect, low-complexity bootstrap for MVPs, prototypes, and proof-of-concepts that can later be updated to the Blues stack easily. +- [The Grunge Stack][the-grunge-stack]: Deployed to a serverless function running Node.js with DynamoDB for persistence. Intended for folks who want to deploy a production-grade application on AWS infrastructure serving millions of users. + +You can use these stacks by proving the `--template` option when running `create-remix`, for example: + +```sh +npx create-remix@latest --template remix-run/blues-stack +``` + +Yes, these are named after music genres. 🤘 Rock on. + +### Community Stacks + +You can [browse the list of community stacks on GitHub.][remix-stack-topic] + +Community stacks can be used by passing the GitHub username/repo combo to the `--template` option when running `create-remix`, for example: + +```sh +npx create-remix@latest --template :username/:repo +``` + +If you want to share your stack with the community, don't forget to tag it with the [remix-stack][remix-stack-topic] topic so others can find it — and yes, we do recommend that you name your own stack after a music sub-genre (not "rock" but "indie"!). + +## Regular Templates and Examples + +For a less opinionated starting point, you can also just use a regular template. + +The Remix repo provides a set of [templates for different environments.][official-templates] + +We also provide a [community-driven examples repository,][examples] with each example showcasing different Remix features, patterns, tools, hosting providers, etc. + +You can use these templates and examples by passing a GitHub shorthand to the `--template` option when running `create-remix`, for example: + +```sh +npx create-remix@latest --template remix-run/examples/basic +``` + +Additionally, if your template is in a private GitHub repo, you can pass a GitHub token via the `--token` option: + + +```sh +npx create-remix@latest --template your-private/repo --token yourtoken +``` + +The [token just needs `repo` access][repo access token]. + +### Local Templates + +You can provide a local directory or tarball on disk to the `--template` option, for example: + +```sh +npx create-remix@latest --template /my/remix-stack +npx create-remix@latest --template /my/remix-stack.tar.gz +npx create-remix@latest --template file:///Users/michael/my-remix-stack.tar.gz +``` + +### Custom Template Tips + +#### Dependency Versions + +If you set any dependencies in package.json to `*`, the Remix CLI will change it to a semver caret of the installed Remix version: + +```diff +- "remix": "*", ++ "remix": "^1.2.3", +``` + +This allows you to not have to regularly update your template to the latest version of that specific package. Of course, you do not have to put `*` if you'd prefer to manually manage the version for that package. + +#### Customize Initialization + +If the template has a `remix.init/index.js` file at the root then that file will be executed after the project has been generated and dependencies have been installed. This gives you a chance to do anything you'd like as part of the initialization of your template. For example, in the blues stack, the `app` property has to be globally unique, so we use the `remix.init/index.js` file to change it to the name of the directory that was created for the project + a couple random characters. + +You could even use `remix.init/index.js` to ask further questions to the developer for additional configuration (using something like [inquirer][inquirer]). Sometimes, you'll need dependencies installed to do this, but those deps are only useful during initialization. In that case, you can also create a `remix.init/package.json` with dependencies and the Remix CLI will install those before running your script. + +After the init script has been run, the `remix.init` folder gets deleted, so you don't need to worry about it cluttering up the finished codebase. + +Do note that consumers can opt out of running the remix.init script. To do so manually, they'll need to run `remix init`. + +[create-remix]: /other-api/create-remix +[remix-app-server]: [/other-api/serve] +[repo access token]: https://github.com/settings/tokens/new?description=Remix%20Private%20Stack%20Access&scopes=repo +[inquirer]: https://npm.im/inquirer +[read-the-feature-announcement-blog-post]: /blog/remix-stacks +[watch-remix-stacks-videos-on-you-tube]: https://www.youtube.com/playlist?list=PLXoynULbYuEC8-gJCqyXo94RufAvSA6R3 +[the-blues-stack]: https://github.com/remix-run/blues-stack +[the-indie-stack]: https://github.com/remix-run/indie-stack +[the-grunge-stack]: https://github.com/remix-run/grunge-stack +[remix-stack-topic]: https://github.com/topics/remix-stack +[official-templates]: https://github.com/remix-run/remix/tree/main/templates +[examples]: https://github.com/remix-run/examples diff --git a/docs/tutorials/blog.md b/docs/tutorials/blog.md index 4bd7fc3ada0..12fcb43c8a7 100644 --- a/docs/tutorials/blog.md +++ b/docs/tutorials/blog.md @@ -36,7 +36,8 @@ npx create-remix@latest --template remix-run/indie-stack blog-tutorial ``` ``` -? Do you want me to run `npm install`? Yes +Install dependencies with npm? +Yes ``` You can read more about the stacks available in [the stacks docs][the-stacks-docs]. diff --git a/docs/tutorials/jokes.md b/docs/tutorials/jokes.md index 3ee82deabd5..40c7c97c21c 100644 --- a/docs/tutorials/jokes.md +++ b/docs/tutorials/jokes.md @@ -94,14 +94,17 @@ This may ask you whether you want to install `create-remix@latest`. Enter `y`. I -Once the setup script has run, it'll ask you a few questions. We'll call our app "remix-jokes", choose "Just the basics", then the "Remix App Server" deploy target, use TypeScript, and have it run the installation for us: +Once the setup script has run, it'll ask you a few questions. We'll call our app "remix-jokes", select to initialize a Git repository and have it run the installation for us: ``` -? Where would you like to create your app? remix-jokes -? What type of app do you want to create? Just the basics -? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server -? TypeScript or JavaScript? TypeScript -? Do you want me to run `npm install`? Yes +Where should we create your new project? +remix-jokes + +Initialize a new git repository? +Yes + +Install dependencies with npm? +Yes ``` Remix can be deployed in a large and growing list of JavaScript environments. The "Remix App Server" is a full-featured [Node.js][node-js] server based on [Express][express]. It's the simplest option, and it satisfies most people's needs, so that's what we're going with for this tutorial. Feel free to experiment in the future! diff --git a/package.json b/package.json index eddc1a65d5f..c990b8b6104 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@playwright/test": "1.33.0", "@remix-run/changelog-github": "^0.0.5", "@rollup/plugin-babel": "^5.2.2", - "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-json": "^5.0.0", "@rollup/plugin-node-resolve": "^11.0.1", "@rollup/plugin-replace": "^5.0.2", "@testing-library/cypress": "^8.0.2", diff --git a/packages/create-remix/__tests__/create-remix-test.ts b/packages/create-remix/__tests__/create-remix-test.ts new file mode 100644 index 00000000000..adf320ec3fe --- /dev/null +++ b/packages/create-remix/__tests__/create-remix-test.ts @@ -0,0 +1,1210 @@ +import type { ChildProcessWithoutNullStreams } from "child_process"; +import { spawn } from "child_process"; +import { tmpdir } from "os"; +import path from "path"; +import { pathToFileURL } from "url"; +import fse from "fs-extra"; +import semver from "semver"; +import stripAnsi from "strip-ansi"; + +import { jestTimeout } from "./setupAfterEnv"; +import { createRemix } from "../create-remix"; +import { server } from "./msw"; + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +// this is so we can mock "npm install" etc. in a cross-platform way +jest.mock("execa"); + +const DOWN = "\x1B\x5B\x42"; +const ENTER = "\x0D"; + +const TEMP_DIR = path.join( + fse.realpathSync(tmpdir()), + `remix-tests-${Math.random().toString(32).slice(2)}` +); +function maskTempDir(string: string) { + return string.replace(TEMP_DIR, ""); +} + +jest.setTimeout(30_000); +beforeAll(async () => { + await fse.remove(TEMP_DIR); + await fse.ensureDir(TEMP_DIR); +}); + +afterAll(async () => { + await fse.remove(TEMP_DIR); +}); + +describe("create-remix CLI", () => { + let tempDirs = new Set(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + for (let dir of tempDirs) { + await fse.remove(dir); + } + tempDirs = new Set(); + }); + + function getProjectDir(name: string) { + let tmpDir = path.join(TEMP_DIR, name); + tempDirs.add(tmpDir); + return tmpDir; + } + + it("supports the --help flag", async () => { + let { stdout } = await execCreateRemix({ + args: ["--help"], + }); + expect(stdout.trim()).toMatchInlineSnapshot(` + "create-remix + + Usage: + + $ create-remix <...options> + + Values: + + projectDir The Remix project directory + + Options: + + --help, -h Print this help message and exit + --version, -V Print the CLI version and exit + --no-color Disable ANSI colors in console output + --no-motion Disable animations in console output + + --template The project template to use + --[no-]install Whether or not to install dependencies after creation + --package-manager The package manager to use + --show-install-output Whether to show the output of the install process + --[no-]init-script Whether or not to run the template's remix.init script, if present + --[no-]git-init Whether or not to initialize a Git repository + --yes, -y Skip all option prompts and run setup + --remix-version, -v The version of Remix to use + + Creating a new project: + + Remix projects are created from templates. A template can be: + + - a GitHub repo shorthand, :username/:repo or :username/:repo/:directory + - the URL of a GitHub repo (or directory within it) + - the URL of a tarball + - a file path to a directory of files + - a file path to a tarball + + $ create-remix my-app --template remix-run/grunge-stack + $ create-remix my-app --template remix-run/remix/templates/remix + $ create-remix my-app --template remix-run/examples/basic + $ create-remix my-app --template :username/:repo + $ create-remix my-app --template :username/:repo/:directory + $ create-remix my-app --template https://github.com/:username/:repo + $ create-remix my-app --template https://github.com/:username/:repo/tree/:branch + $ create-remix my-app --template https://github.com/:username/:repo/tree/:branch/:directory + $ create-remix my-app --template https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz + $ create-remix my-app --template https://example.com/remix-template.tar.gz + $ create-remix my-app --template ./path/to/remix-template + $ create-remix my-app --template ./path/to/remix-template.tar.gz + + To create a new project from a template in a private GitHub repo, + pass the \`token\` flag with a personal access token with access + to that repo. + + Initialize a project: + + Remix project templates may contain a \`remix.init\` directory + with a script that initializes the project. This script automatically + runs during \`remix create\`, but if you ever need to run it manually + you can run: + + $ remix init" + `); + }); + + it("supports the --version flag", async () => { + let { stdout } = await execCreateRemix({ + args: ["--version"], + }); + expect(!!semver.valid(stdout.trim())).toBe(true); + }); + + it("allows you to go through the prompts", async () => { + let projectDir = getProjectDir("prompts"); + + let { status, stderr } = await execCreateRemix({ + args: [], + interactions: [ + { + question: /where.*create.*project/i, + type: [projectDir, ENTER], + }, + { + question: /init.*git/i, + type: ["n"], + }, + { + question: /install dependencies/i, + type: ["n"], + }, + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("supports the --yes flag", async () => { + let projectDir = getProjectDir("yes"); + + let { status, stderr } = await execCreateRemix({ + args: [projectDir, "--yes", "--no-git-init", "--no-install"], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("errors when project directory isn't provided when shell isn't interactive", async () => { + let projectDir = getProjectDir("non-interactive-no-project-dir"); + + let { status, stderr } = await execCreateRemix({ + args: ["--no-install"], + interactive: false, + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! No project directory provided"` + ); + expect(status).toBe(1); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); + }); + + it("errors when project directory isn't empty when shell isn't interactive", async () => { + let notEmptyDir = getProjectDir("non-interactive-not-empty-dir"); + fse.mkdirSync(notEmptyDir); + fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); + + let { status, stderr } = await execCreateRemix({ + args: [notEmptyDir, "--no-install"], + interactive: false, + }); + + expect( + stderr.trim().replace("\\", "/") // Normalize Windows path + ).toMatchInlineSnapshot( + `"▲ Oh no! Project directory \\"/non-interactive-not-empty-dir\\" is not empty"` + ); + expect(status).toBe(1); + expect(fse.existsSync(path.join(notEmptyDir, "package.json"))).toBeFalsy(); + expect(fse.existsSync(path.join(notEmptyDir, "app/root.tsx"))).toBeFalsy(); + }); + + it("works for GitHub username/repo combo", async () => { + let projectDir = getProjectDir("github-username-repo"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "remix-fake-tester-username/remix-fake-tester-repo", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for GitHub username/repo/path combo", async () => { + let projectDir = getProjectDir("github-username-repo-path"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "fake-remix-tester/nested-dir/stack", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("fails for GitHub username/repo/path combo when path doesn't exist", async () => { + let projectDir = getProjectDir("github-username-repo-path-missing"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "fake-remix-tester/nested-dir/this/path/does/not/exist", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! The path \\"this/path/does/not/exist\\" was not found in this GitHub repo."` + ); + expect(status).toBe(1); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); + }); + + it("fails for private GitHub username/repo combo without a token", async () => { + let projectDir = getProjectDir("private-repo-no-token"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "private-org/private-repo", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("succeeds for private GitHub username/repo combo with a valid token", async () => { + let projectDir = getProjectDir("github-username-repo-with-token"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "private-org/private-repo", + "--no-git-init", + "--no-install", + "--token", + "valid-token", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for remote tarballs", async () => { + let projectDir = getProjectDir("remote-tarball"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://example.com/remix-stack.tar.gz", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("fails for private github release tarballs", async () => { + let projectDir = getProjectDir("private-release-tarball-no-token"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://github.com/private-org/private-repo/releases/download/v0.0.1/stack.tar.gz", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("succeeds for private github release tarballs when including token", async () => { + let projectDir = getProjectDir("private-release-tarball-with-token"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://github.com/private-org/private-repo/releases/download/v0.0.1/stack.tar.gz", + "--token", + "valid-token", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for different branches and nested paths", async () => { + let projectDir = getProjectDir("diff-branch"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://github.com/fake-remix-tester/nested-dir/tree/dev/stack", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("fails for different branches and nested paths when path doesn't exist", async () => { + let projectDir = getProjectDir("diff-branch-invalid-path"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://github.com/fake-remix-tester/nested-dir/tree/dev/this/path/does/not/exist", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! The path \\"this/path/does/not/exist\\" was not found in this GitHub repo."` + ); + expect(status).toBe(1); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); + }); + + it("works for a path to a tarball on disk", async () => { + let projectDir = getProjectDir("local-tarball"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "arc.tar.gz"), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for a file URL to a tarball on disk", async () => { + let projectDir = getProjectDir("file-url-tarball"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + pathToFileURL( + path.join(__dirname, "fixtures", "arc.tar.gz") + ).toString(), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for a file path to a directory on disk", async () => { + let projectDir = getProjectDir("local-directory"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures/stack"), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("works for a file URL to a directory on disk", async () => { + let projectDir = getProjectDir("file-url-directory"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + pathToFileURL(path.join(__dirname, "fixtures/stack")).toString(), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("prompts to run remix.init script when installing dependencies", async () => { + let projectDir = getProjectDir("remix-init-prompt"); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "successful-remix-init"), + "--no-git-init", + "--debug", + ], + interactions: [ + { + question: /install dependencies/i, + type: ["y", ENTER], + }, + { + question: /init script/i, + type: ["y", ENTER], + }, + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain(`Template's remix.init script complete`); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeFalsy(); + }); + + it("doesn't prompt to run remix.init script when not installing dependencies", async () => { + let projectDir = getProjectDir("remix-init-skip-on-no-install"); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "successful-remix-init"), + "--no-git-init", + ], + interactions: [ + { + question: /install dependencies/i, + type: ["n", ENTER], + }, + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain(`Skipping template's remix.init script.`); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + + // Init script hasn't run so file exists + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); + + // Init script hasn't run so remix.init directory still exists + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); + + it("runs remix.init script when --install and --init-script flags are passed", async () => { + let projectDir = getProjectDir("remix-init-prompt-with-flags"); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "successful-remix-init"), + "--no-git-init", + "--install", + "--init-script", + "--debug", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + + expect(stdout).toContain(`Template's remix.init script complete`); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeFalsy(); + }); + + it("doesn't run remix.init script when --no-install flag is passed, even when --init-script flag is passed", async () => { + let projectDir = getProjectDir( + "remix-init-skip-on-no-install-with-init-flag" + ); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "successful-remix-init"), + "--no-git-init", + "--no-install", + "--init-script", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain(`Skipping template's remix.init script.`); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + + // Init script hasn't run so file exists + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); + + // Init script hasn't run so remix.init directory still exists + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); + + it("doesn't run remix.init script when --no-init-script flag is passed", async () => { + let projectDir = getProjectDir("remix-init-skip-on-no-init-flag"); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "successful-remix-init"), + "--no-git-init", + "--install", + "--no-init-script", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain(`Skipping template's remix.init script.`); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + + // Init script hasn't run so file exists + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); + + // Init script hasn't run so remix.init directory still exists + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); + + it("throws an error when invalid remix.init script when automatically ran", async () => { + let projectDir = getProjectDir("invalid-remix-init-auto"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "failing-remix-init"), + "--no-git-init", + "--install", + "--init-script", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! Template's remix.init script failed"` + ); + expect(status).toBe(1); + expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); + expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); + }); + + it("runs npm install by default", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = undefined; + + let projectDir = getProjectDir("npm-install-default"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["install"]), + expect.anything() + ); + + process.env.npm_config_user_agent = originalUserAgent; + }); + + it("runs npm install if package manager in user agent string is unknown", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "unknown_package_manager/1.0.0 npm/? node/v14.17.0 linux x64"; + + let projectDir = getProjectDir("npm-install-on-unknown-package-manager"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["install"]), + expect.anything() + ); + + process.env.npm_config_user_agent = originalUserAgent; + }); + + it("recognizes when npm was used to run the command", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "npm/8.19.4 npm/? node/v14.17.0 linux x64"; + + let projectDir = getProjectDir("npm-install-from-user-agent"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["install"]), + expect.anything() + ); + process.env.npm_config_user_agent = originalUserAgent; + }); + + it("recognizes when Yarn was used to run the command", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "yarn/1.22.18 npm/? node/v14.17.0 linux x64"; + + let projectDir = getProjectDir("yarn-create-from-user-agent"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "yarn", + expect.arrayContaining(["install"]), + expect.anything() + ); + process.env.npm_config_user_agent = originalUserAgent; + }); + + it("recognizes when pnpm was used to run the command", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "pnpm/6.32.3 npm/? node/v14.17.0 linux x64"; + + let projectDir = getProjectDir("pnpm-create-from-user-agent"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "pnpm", + expect.arrayContaining(["install"]), + expect.anything() + ); + process.env.npm_config_user_agent = originalUserAgent; + }); + + it("supports specifying the package manager, regardless of user agent", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "yarn/1.22.18 npm/? node/v14.17.0 linux x64"; + + let projectDir = getProjectDir("pnpm-create-override"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createRemix([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + "--package-manager", + "pnpm", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "pnpm", + expect.arrayContaining(["install"]), + expect.anything() + ); + process.env.npm_config_user_agent = originalUserAgent; + }); + + describe("errors", () => { + it("identifies when a github repo is not accessible (403)", async () => { + let projectDir = getProjectDir("repo-403"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "error-username/403", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 403 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("identifies when a github repo does not exist (404)", async () => { + let projectDir = getProjectDir("repo-404"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "error-username/404", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("identifies when something unknown goes wrong with the repo request (4xx)", async () => { + let projectDir = getProjectDir("repo-4xx"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "error-username/400", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 400 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("identifies when a remote tarball does not exist (404)", async () => { + let projectDir = getProjectDir("remote-tarball-404"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://example.com/error/404/remix-stack.tar.gz", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file. The request responded with a 404 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("identifies when a remote tarball does not exist (4xx)", async () => { + let projectDir = getProjectDir("remote-tarball-4xx"); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "https://example.com/error/400/remix-stack.tar.gz", + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toMatchInlineSnapshot( + `"▲ Oh no! There was a problem fetching the file. The request responded with a 400 status. Please try again later."` + ); + expect(status).toBe(1); + }); + + it("doesn't allow creating an app in a dir if it's not empty and then prompts for an empty dir", async () => { + let emptyDir = getProjectDir("prompt-for-dir-on-non-empty-dir"); + + let notEmptyDir = getProjectDir("not-empty-dir"); + fse.mkdirSync(notEmptyDir); + fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + notEmptyDir, + "--template", + path.join(__dirname, "fixtures/stack"), + "--no-git-init", + "--no-install", + ], + interactions: [ + { + question: /where.*create.*project/i, + type: [emptyDir, ENTER], + }, + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain( + `Hmm... "${maskTempDir(notEmptyDir)}" is not empty!` + ); + expect(status).toBe(0); + expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("allows creating an app in the current dir if it's empty", async () => { + let emptyDir = getProjectDir("current-dir-if-empty"); + fse.mkdirSync(emptyDir); + + let { status, stderr } = await execCreateRemix({ + cwd: emptyDir, + args: [ + ".", + "--template", + path.join(__dirname, "fixtures/stack"), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("doesn't allow creating an app in the current dir if it's not empty", async () => { + let emptyDir = getProjectDir("prompt-for-dir-if-current-dir-not-empty"); + let notEmptyDir = getProjectDir("not-empty-dir"); + fse.mkdirSync(notEmptyDir); + fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); + + let { status, stdout, stderr } = await execCreateRemix({ + cwd: notEmptyDir, + args: [ + ".", + "--template", + path.join(__dirname, "fixtures/stack"), + "--no-git-init", + "--no-install", + ], + interactions: [ + { + question: /where.*create.*project/i, + type: [emptyDir, ENTER], + }, + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(stdout).toContain(`Hmm... "." is not empty!`); + expect(status).toBe(0); + expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); + }); + }); + + describe("supports proxy usage", () => { + beforeAll(() => { + server.close(); + }); + afterAll(() => { + server.listen({ onUnhandledRequest: "error" }); + }); + it("uses the proxy from env var", async () => { + let projectDir = await getProjectDir("template"); + + let { stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + "remix-run/grunge-stack", + "--no-install", + "--no-git-init", + "--debug", + ], + mockNetwork: false, + env: { HTTPS_PROXY: "http://127.0.0.1:33128" }, + }); + + expect(stderr.trim()).toMatch("127.0.0.1:33"); + }); + }); +}); + +async function execCreateRemix({ + args = [], + interactions = [], + interactive = true, + env = {}, + mockNetwork = true, + cwd, +}: { + args: string[]; + interactive?: boolean; + interactions?: ShellInteractions; + env?: Record; + mockNetwork?: boolean; + cwd?: string; +}) { + let proc = spawn( + "node", + [ + "--require", + require.resolve("esbuild-register"), + ...(mockNetwork + ? ["--require", path.join(__dirname, "./msw-register.ts")] + : []), + path.resolve(__dirname, "../cli.ts"), + ...args, + ], + { + cwd, + stdio: [null, null, null], + env: { + ...process.env, + ...env, + ...(interactive ? { CREATE_REMIX_FORCE_INTERACTIVE: "true" } : {}), + }, + } + ); + + return await interactWithShell(proc, interactions); +} + +interface ShellResult { + status: number | "timeout" | null; + stdout: string; + stderr: string; +} + +type ShellInteractions = Array< + | { question: RegExp; type: Array; answer?: never } + | { question: RegExp; answer: RegExp; type?: never } +>; + +async function interactWithShell( + proc: ChildProcessWithoutNullStreams, + interactions: ShellInteractions +): Promise { + proc.stdin.setDefaultEncoding("utf-8"); + + let deferred = defer(); + + let stepNumber = 0; + + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (chunk: unknown) => { + if (chunk instanceof Buffer) { + chunk = String(chunk); + } + if (typeof chunk !== "string") { + console.error({ stdoutChunk: chunk }); + throw new Error("stdout chunk is not a string"); + } + stdout += stripAnsi(maskTempDir(chunk)); + let step = interactions[stepNumber]; + if (!step) return; + let { question, answer, type } = step; + if (question.test(chunk)) { + if (answer) { + let currentSelection = chunk + .split("\n") + .slice(1) + .find( + (line) => + line.includes("❯") || line.includes(">") || line.includes("●") + ); + + if (currentSelection && answer.test(currentSelection)) { + proc.stdin.write(ENTER); + stepNumber += 1; + } else { + proc.stdin.write(DOWN); + } + } else if (type) { + for (let command of type) { + proc.stdin.write(command); + } + stepNumber += 1; + } + } + + if (stepNumber === interactions.length) { + proc.stdin.end(); + } + }); + + proc.stderr.on("data", (chunk: unknown) => { + if (chunk instanceof Buffer) { + chunk = String(chunk); + } + if (typeof chunk !== "string") { + console.error({ stderrChunk: chunk }); + throw new Error("stderr chunk is not a string"); + } + stderr += stripAnsi(maskTempDir(chunk)); + }); + + proc.on("close", (status) => { + deferred.resolve({ status, stdout, stderr }); + }); + + // this ensures that if we do timeout we at least get as much useful + // output as possible. + let timeout = setTimeout(() => { + if (deferred.state.current === "pending") { + proc.kill(); + deferred.resolve({ status: "timeout", stdout, stderr }); + } + }, jestTimeout); + + let result = await deferred.promise; + clearTimeout(timeout); + + return result; +} + +function defer() { + let resolve: (value: Value) => void, reject: (reason?: any) => void; + let state: { current: "pending" | "resolved" | "rejected" } = { + current: "pending", + }; + let promise = new Promise((res, rej) => { + resolve = (value: Value) => { + state.current = "resolved"; + return res(value); + }; + reject = (reason?: any) => { + state.current = "rejected"; + return rej(reason); + }; + }); + return { promise, resolve: resolve!, reject: reject!, state }; +} diff --git a/packages/create-remix/__tests__/fixtures/arc.tar.gz b/packages/create-remix/__tests__/fixtures/arc.tar.gz new file mode 100644 index 00000000000..9b90ab69219 Binary files /dev/null and b/packages/create-remix/__tests__/fixtures/arc.tar.gz differ diff --git a/packages/create-remix/__tests__/fixtures/blank/package.json b/packages/create-remix/__tests__/fixtures/blank/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/blank/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/remix-dev/__tests__/fixtures/failing-remix-init.tar.gz b/packages/create-remix/__tests__/fixtures/examples-main.tar.gz similarity index 60% rename from packages/remix-dev/__tests__/fixtures/failing-remix-init.tar.gz rename to packages/create-remix/__tests__/fixtures/examples-main.tar.gz index 6230302b699..f2a7d82029b 100644 Binary files a/packages/remix-dev/__tests__/fixtures/failing-remix-init.tar.gz and b/packages/create-remix/__tests__/fixtures/examples-main.tar.gz differ diff --git a/packages/create-remix/__tests__/fixtures/failing-remix-init/package.json b/packages/create-remix/__tests__/fixtures/failing-remix-init/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/failing-remix-init/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/create-remix/__tests__/fixtures/failing-remix-init/remix.init/index.js b/packages/create-remix/__tests__/fixtures/failing-remix-init/remix.init/index.js new file mode 100644 index 00000000000..2dbd14d4324 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/failing-remix-init/remix.init/index.js @@ -0,0 +1,3 @@ +module.exports = () => { + throw new Error("💣"); +}; diff --git a/packages/create-remix/__tests__/fixtures/nested-dir-repo.tar.gz b/packages/create-remix/__tests__/fixtures/nested-dir-repo.tar.gz new file mode 100644 index 00000000000..e0db0c2fc58 Binary files /dev/null and b/packages/create-remix/__tests__/fixtures/nested-dir-repo.tar.gz differ diff --git a/packages/create-remix/__tests__/fixtures/remix-repo.tar.gz b/packages/create-remix/__tests__/fixtures/remix-repo.tar.gz new file mode 100644 index 00000000000..e804a00ed5e Binary files /dev/null and b/packages/create-remix/__tests__/fixtures/remix-repo.tar.gz differ diff --git a/packages/remix-dev/__tests__/fixtures/stack-init-ts.tar.gz b/packages/create-remix/__tests__/fixtures/stack.tar.gz similarity index 88% rename from packages/remix-dev/__tests__/fixtures/stack-init-ts.tar.gz rename to packages/create-remix/__tests__/fixtures/stack.tar.gz index 61600d6f2a7..edde2637f01 100644 Binary files a/packages/remix-dev/__tests__/fixtures/stack-init-ts.tar.gz and b/packages/create-remix/__tests__/fixtures/stack.tar.gz differ diff --git a/packages/create-remix/__tests__/fixtures/stack/.gitignore b/packages/create-remix/__tests__/fixtures/stack/.gitignore new file mode 100644 index 00000000000..3f7bf98da3e --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/packages/create-remix/__tests__/fixtures/stack/README.md b/packages/create-remix/__tests__/fixtures/stack/README.md new file mode 100644 index 00000000000..da8d02ad77c --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/README.md @@ -0,0 +1,38 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` diff --git a/packages/create-remix/__tests__/fixtures/stack/app/entry.client.tsx b/packages/create-remix/__tests__/fixtures/stack/app/entry.client.tsx new file mode 100644 index 00000000000..3eec1fd0a02 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/packages/create-remix/__tests__/fixtures/stack/app/entry.server.tsx b/packages/create-remix/__tests__/fixtures/stack/app/entry.server.tsx new file mode 100644 index 00000000000..068773556ca --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/app/entry.server.tsx @@ -0,0 +1,22 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + // eslint-disable-next-line testing-library/render-result-naming-convention + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/create-remix/__tests__/fixtures/stack/app/root.tsx b/packages/create-remix/__tests__/fixtures/stack/app/root.tsx new file mode 100644 index 00000000000..1fedc5b72b7 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/app/root.tsx @@ -0,0 +1,25 @@ +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/create-remix/__tests__/fixtures/stack/app/utils.ts b/packages/create-remix/__tests__/fixtures/stack/app/utils.ts new file mode 100644 index 00000000000..304bb4e19c1 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/app/utils.ts @@ -0,0 +1 @@ +// this is a utility file diff --git a/packages/create-remix/__tests__/fixtures/stack/package.json b/packages/create-remix/__tests__/fixtures/stack/package.json new file mode 100644 index 00000000000..3ab75a6c52e --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/package.json @@ -0,0 +1,15 @@ +{ + "name": "remix-template-remix", + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build" + }, + "dependencies": {}, + "devDependencies": {}, + "engines": { + "node": ">=14" + } +} diff --git a/packages/create-remix/__tests__/fixtures/stack/public/favicon.ico b/packages/create-remix/__tests__/fixtures/stack/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/packages/create-remix/__tests__/fixtures/stack/public/favicon.ico differ diff --git a/packages/create-remix/__tests__/fixtures/stack/remix.config.js b/packages/create-remix/__tests__/fixtures/stack/remix.config.js new file mode 100644 index 00000000000..5bb0822e720 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/remix.config.js @@ -0,0 +1,9 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + serverBuildTarget: "node-cjs", + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/packages/create-remix/__tests__/fixtures/stack/remix.env.d.ts b/packages/create-remix/__tests__/fixtures/stack/remix.env.d.ts new file mode 100644 index 00000000000..72e2affe311 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/create-remix/__tests__/fixtures/stack/remix.init/index.js b/packages/create-remix/__tests__/fixtures/stack/remix.init/index.js new file mode 100644 index 00000000000..d3dd71ce24c --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/remix.init/index.js @@ -0,0 +1 @@ +// this is the init file diff --git a/packages/create-remix/__tests__/fixtures/stack/tsconfig.json b/packages/create-remix/__tests__/fixtures/stack/tsconfig.json new file mode 100644 index 00000000000..7d43861a8fa --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/stack/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/create-remix/__tests__/fixtures/successful-remix-init/package.json b/packages/create-remix/__tests__/fixtures/successful-remix-init/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/successful-remix-init/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/create-remix/__tests__/fixtures/successful-remix-init/remix.init/index.js b/packages/create-remix/__tests__/fixtures/successful-remix-init/remix.init/index.js new file mode 100644 index 00000000000..5d76eeef6a0 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/successful-remix-init/remix.init/index.js @@ -0,0 +1,9 @@ +const fs = require("fs"); +const path = require("path"); + +module.exports = ({ rootDirectory }) => { + fs.writeFileSync( + path.join(rootDirectory, "test.txt"), + "added via remix.init" + ); +}; diff --git a/packages/create-remix/__tests__/fixtures/tar.js b/packages/create-remix/__tests__/fixtures/tar.js new file mode 100644 index 00000000000..0f35038fec2 --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/tar.js @@ -0,0 +1,21 @@ +const tar = require("tar-fs"); +const fs = require("fs"); +const path = require("path"); + +let files = fs.readdirSync(__dirname); +let dirs = files.filter((file) => + fs.statSync(path.join(__dirname, file)).isDirectory() +); + +for (let dir of dirs) { + let fullPath = path.join(__dirname, dir); + console.log(`Creating archive for ${fullPath}`); + tar + .pack(fullPath, { + map(header) { + header.name = dir + "/" + header.name; + return header; + }, + }) + .pipe(fs.createWriteStream(path.join(__dirname, `${dir}.tar.gz`))); +} diff --git a/packages/create-remix/__tests__/github-mocks.ts b/packages/create-remix/__tests__/github-mocks.ts new file mode 100644 index 00000000000..ecff71fc0ac --- /dev/null +++ b/packages/create-remix/__tests__/github-mocks.ts @@ -0,0 +1,364 @@ +import * as nodePath from "path"; +import fsp from "fs/promises"; +import invariant from "tiny-invariant"; +import type { setupServer } from "msw/node"; +import { rest } from "msw"; + +type RequestHandler = Parameters[0]; + +async function isDirectory(d: string) { + try { + return (await fsp.lstat(d)).isDirectory(); + } catch { + return false; + } +} +async function isFile(d: string) { + try { + return (await fsp.lstat(d)).isFile(); + } catch { + return false; + } +} + +type GHContentsDescription = { + name: string; + path: string; + sha: string; + size: number; + url: string; + html_url: string; + git_url: string; + download_url: string | null; + type: "dir" | "file"; + _links: { + self: string; + git: string; + html: string; + }; +}; + +type GHContent = { + sha: string; + node_id: string; + size: number; + url: string; + content: string; + encoding: "base64"; +}; + +type ResponseResolver = Parameters[1]; + +let sendTarball: ResponseResolver = async (req, res, ctx) => { + let { owner, repo } = req.params; + invariant(typeof owner === "string", "owner must be a string"); + invariant(typeof repo === "string", "repo must be a string"); + + let pathToTarball: string; + if (owner === "remix-run" && repo === "examples") { + pathToTarball = nodePath.join(__dirname, "fixtures/examples-main.tar.gz"); + } else if (owner === "remix-run" && repo === "remix") { + pathToTarball = nodePath.join(__dirname, "fixtures/remix-repo.tar.gz"); + } else if (owner === "fake-remix-tester" && repo === "nested-dir") { + pathToTarball = nodePath.join(__dirname, "fixtures/nested-dir-repo.tar.gz"); + } else { + pathToTarball = nodePath.join(__dirname, "fixtures/stack.tar.gz"); + } + + let fileBuffer = await fsp.readFile(pathToTarball); + + return res( + ctx.body(fileBuffer), + ctx.set("Content-Type", "application/x-gzip") + ); +}; + +let githubHandlers: Array = [ + rest.head( + `https://github.com/remix-run/remix/tree/main/:type/:name`, + async (_req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/remix-run/examples/tree/main/:type/:name`, + async (_req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/error-username/:status`, + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head(`https://github.com/:owner/:repo`, async (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.head( + `https://api.github.com/repos/error-username/:status`, + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head( + `https://api.github.com/repos/private-org/private-repo`, + async (req, res, ctx) => { + let status = + req.headers.get("Authorization") === "token valid-token" ? 200 : 404; + return res(ctx.status(status)); + } + ), + rest.head( + `https://api.github.com/repos/:owner/:repo`, + async (req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.head( + `https://github.com/:owner/:repo/tree/:branch/:path*`, + async (req, res, ctx) => { + return res(ctx.status(200)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/git/trees/:branch`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + + return res( + ctx.status(200), + ctx.json({ + sha: "7d906ff5bbb79401a4a8ec1e1799845ed502c0a1", + url: `https://api.github.com/repos/${owner}/${repo}/trees/7d906ff5bbb79401a4a8ec1e1799845ed502c0a1`, + tree: [ + { + path: "package.json", + mode: "040000", + type: "blob", + sha: "a405cd8355516db9c96e1467fb14b74c97ac0a65", + size: 138, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/a405cd8355516db9c96e1467fb14b74c97ac0a65`, + }, + { + path: "stack", + mode: "040000", + type: "tree", + sha: "3f350a670e8fefd58535a9e1878539dc19afb4b5", + url: `https://api.github.com/repos/${owner}/${repo}/trees/3f350a670e8fefd58535a9e1878539dc19afb4b5`, + }, + ], + }) + ); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/contents/:path`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + if (typeof req.params.path !== "string") { + throw new Error("req.params.path must be a string"); + } + let path = decodeURIComponent(req.params.path).trim(); + let isMockable = owner === "remix-run" && repo === "remix"; + + if (!isMockable) { + let message = `Attempting to get content description for unmockable resource: ${owner}/${repo}/${path}`; + console.error(message); + throw new Error(message); + } + + let localPath = nodePath.join(__dirname, "../../..", path); + let isLocalDir = await isDirectory(localPath); + let isLocalFile = await isFile(localPath); + + if (!isLocalDir && !isLocalFile) { + return res( + ctx.status(404), + ctx.json({ + message: "Not Found", + documentation_url: + "https://docs.github.com/rest/reference/repos#get-repository-content", + }) + ); + } + + if (isLocalFile) { + let encoding = "base64" as const; + let content = await fsp.readFile(localPath, { encoding: "utf-8" }); + return res( + ctx.status(200), + ctx.json({ + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }) + ); + } + + let dirList = await fsp.readdir(localPath); + + let contentDescriptions = await Promise.all( + dirList.map(async (name): Promise => { + let relativePath = nodePath.join(path, name); + // NOTE: this is a cheat-code so we don't have to determine the sha of the file + // and our sha endpoint handler doesn't have to do a reverse-lookup. + let sha = relativePath; + let fullPath = nodePath.join(localPath, name); + let isDir = await isDirectory(fullPath); + let size = isDir ? 0 : (await fsp.stat(fullPath)).size; + return { + name, + path: relativePath, + sha, + size, + url: `https://api.github.com/repos/${owner}/${repo}/contents/${path}?${req.url.searchParams}`, + html_url: `https://github.com/${owner}/${repo}/tree/main/${path}`, + git_url: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`, + download_url: null, + type: isDir ? "dir" : "file", + _links: { + self: `https://api.github.com/repos/${owner}/${repo}/contents/${path}${req.url.searchParams}`, + git: `https://api.github.com/repos/${owner}/${repo}/git/trees/${sha}`, + html: `https://github.com/${owner}/${repo}/tree/main/${path}`, + }, + }; + }) + ); + + return res(ctx.json(contentDescriptions)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/git/blobs/:sha`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + if (typeof req.params.sha !== "string") { + throw new Error("req.params.sha must be a string"); + } + let sha = decodeURIComponent(req.params.sha).trim(); + // if the sha includes a "/" that means it's not a sha but a relativePath + // and therefore the client is getting content it got from the local + // mock environment, not the actual github API. + if (!sha.includes("/")) { + let message = `Attempting to get content for sha, but no sha exists locally: ${sha}`; + console.error(message); + throw new Error(message); + } + + // NOTE: we cheat a bit and in the contents/:path handler, we set the sha to the relativePath + let relativePath = sha; + let fullPath = nodePath.join(__dirname, "..", relativePath); + let encoding = "base64" as const; + let size = (await fsp.stat(fullPath)).size; + let content = await fsp.readFile(fullPath, { encoding: "utf-8" }); + + let resource: GHContent = { + sha, + node_id: `${sha}_node_id`, + size, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }; + + return res(ctx.json(resource)); + } + ), + rest.get( + `https://api.github.com/repos/:owner/:repo/contents/:path*`, + async (req, res, ctx) => { + let { owner, repo } = req.params; + + let relativePath = req.params.path; + if (typeof relativePath !== "string") { + throw new Error("req.params.path must be a string"); + } + let fullPath = nodePath.join(__dirname, "..", relativePath); + let encoding = "base64" as const; + let size = (await fsp.stat(fullPath)).size; + let content = await fsp.readFile(fullPath, { encoding: "utf-8" }); + let sha = `${relativePath}_sha`; + + let resource: GHContent = { + sha, + node_id: `${req.params.path}_node_id`, + size, + url: `https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, + content: Buffer.from(content, "utf-8").toString(encoding), + encoding, + }; + + return res(ctx.json(resource)); + } + ), + rest.get( + `https://codeload.github.com/private-org/private-repo/tar.gz/:branch`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + return sendTarball(req, res, ctx); + } + ), + rest.get( + `https://codeload.github.com/:owner/:repo/tar.gz/:branch`, + sendTarball + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/tarball`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + + return sendTarball(req, res, ctx); + } + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/releases/tags/:tag`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + let { tag } = req.params; + return res( + ctx.status(200), + ctx.json({ + assets: [ + { + browser_download_url: `https://github.com/private-org/private-repo/releases/download/${tag}/stack.tar.gz`, + id: "working-asset-id", + }, + ], + }) + ); + } + ), + rest.get( + `https://api.github.com/repos/private-org/private-repo/releases/assets/working-asset-id`, + (req, res, ctx) => { + if (req.headers.get("Authorization") !== "token valid-token") { + return res(ctx.status(404)); + } + req.params.owner = "private-org"; + req.params.repo = "private-repo"; + return sendTarball(req, res, ctx); + } + ), + rest.get( + `https://api.github.com/repos/error-username/:status/tarball`, + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.get(`https://api.github.com/repos/:owner/:repo/tarball`, sendTarball), + rest.get(`https://api.github.com/repos/:repo*`, async (req, res, ctx) => { + return res(ctx.json({ default_branch: "main" })); + }), +]; + +export { githubHandlers }; diff --git a/packages/create-remix/__tests__/msw-register.ts b/packages/create-remix/__tests__/msw-register.ts new file mode 100644 index 00000000000..50c01d47431 --- /dev/null +++ b/packages/create-remix/__tests__/msw-register.ts @@ -0,0 +1,9 @@ +import process from "node:process"; + +import { server } from "./msw"; + +server.listen({ onUnhandledRequest: "error" }); + +process.on("exit", () => { + server.close(); +}); diff --git a/packages/create-remix/__tests__/msw.ts b/packages/create-remix/__tests__/msw.ts new file mode 100644 index 00000000000..7a287db0e28 --- /dev/null +++ b/packages/create-remix/__tests__/msw.ts @@ -0,0 +1,42 @@ +import path from "path"; +import fsp from "fs/promises"; +import { setupServer } from "msw/node"; +import { rest } from "msw"; + +import { githubHandlers } from "./github-mocks"; + +type RequestHandler = Parameters[0]; + +let miscHandlers: Array = [ + rest.get("https://registry.npmjs.org/remix/latest", async (req, res, ctx) => { + return res(ctx.body(JSON.stringify({ version: "123.0.0" }))); + }), + rest.head( + "https://example.com/error/:status/remix-stack.tar.gz", + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.get( + "https://example.com/error/:status/remix-stack.tar.gz", + async (req, res, ctx) => { + return res(ctx.status(Number(req.params.status))); + } + ), + rest.head("https://example.com/remix-stack.tar.gz", async (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.get("https://example.com/remix-stack.tar.gz", async (req, res, ctx) => { + let fileBuffer = await fsp.readFile( + path.join(__dirname, "./fixtures/stack.tar.gz") + ); + + return res( + ctx.body(fileBuffer), + ctx.set("Content-Type", "application/x-gzip") + ); + }), +]; + +let server = setupServer(...githubHandlers, ...miscHandlers); +export { server }; diff --git a/packages/create-remix/__tests__/setupAfterEnv.ts b/packages/create-remix/__tests__/setupAfterEnv.ts new file mode 100644 index 00000000000..6a8ce7089e7 --- /dev/null +++ b/packages/create-remix/__tests__/setupAfterEnv.ts @@ -0,0 +1,3 @@ +export let jestTimeout = process.platform === "win32" ? 20_000 : 10_000; + +jest.setTimeout(jestTimeout); diff --git a/packages/create-remix/cli.ts b/packages/create-remix/cli.ts index 0a0f03a6628..2e9e06d9af9 100644 --- a/packages/create-remix/cli.ts +++ b/packages/create-remix/cli.ts @@ -1,13 +1,13 @@ -import { cli } from "@remix-run/dev"; - -let args = process.argv.slice(2); - -cli - .run(["create", ...args]) - .then(() => { - process.exit(0); - }) - .catch((error: Error) => { - console.error(args.includes("--debug") ? error : error.message); - process.exit(1); - }); +import process from "node:process"; + +import { createRemix } from "./create-remix"; + +process.on("SIGINT", () => process.exit(0)); +process.on("SIGTERM", () => process.exit(0)); + +let argv = process.argv.slice(2).filter((arg) => arg !== "--"); + +createRemix(argv).then( + () => process.exit(0), + () => process.exit(1) +); diff --git a/packages/create-remix/copy-template.ts b/packages/create-remix/copy-template.ts new file mode 100644 index 00000000000..e46abd320a0 --- /dev/null +++ b/packages/create-remix/copy-template.ts @@ -0,0 +1,518 @@ +import process from "node:process"; +import url from "node:url"; +import fs from "node:fs"; +import fse from "fs-extra"; +import path from "node:path"; +import stream from "node:stream"; +import { promisify } from "node:util"; +import fetch from "node-fetch"; +import gunzip from "gunzip-maybe"; +import tar from "tar-fs"; +import { ProxyAgent } from "proxy-agent"; + +import { color, isUrl } from "./utils"; + +const defaultAgent = new ProxyAgent(); +const httpsAgent = new ProxyAgent(); +httpsAgent.protocol = "https:"; +function agent(url: string) { + return new URL(url).protocol === "https:" ? httpsAgent : defaultAgent; +} + +export async function copyTemplate( + template: string, + destPath: string, + options: CopyTemplateOptions +) { + let { log = () => {} } = options; + + /** + * Valid templates are: + * - local file or directory on disk + * - github owner/repo shorthand + * - github owner/repo/directory shorthand + * - full github repo URL + * - any tarball URL + */ + + try { + if (isLocalFilePath(template)) { + log(`Using the template from local file at "${template}"`); + let filepath = template.startsWith("file://") + ? url.fileURLToPath(template) + : template; + await copyTemplateFromLocalFilePath(filepath, destPath); + return; + } + + if (isGithubRepoShorthand(template)) { + log(`Using the template from the "${template}" repo`); + await copyTemplateFromGithubRepoShorthand(template, destPath, options); + return; + } + + if (isValidGithubRepoUrl(template)) { + log(`Using the template from "${template}"`); + await copyTemplateFromGithubRepoUrl(template, destPath, options); + return; + } + + if (isUrl(template)) { + log(`Using the template from "${template}"`); + await copyTemplateFromGenericUrl(template, destPath, options); + return; + } + + throw new CopyTemplateError( + `"${color.bold(template)}" is an invalid template. Run ${color.bold( + "create-remix --help" + )} to see supported template formats.` + ); + } catch (error) { + await options.onError(error); + } +} + +interface CopyTemplateOptions { + debug?: boolean; + token?: string; + onError(error: unknown): any; + log?(message: string): any; +} + +function isLocalFilePath(input: string): boolean { + try { + return ( + input.startsWith("file://") || + fs.existsSync( + path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + ) + ); + } catch (_) { + return false; + } +} + +async function copyTemplateFromRemoteTarball( + url: string, + destPath: string, + options: CopyTemplateOptions +) { + return await downloadAndExtractTarball(destPath, url, options); +} + +async function copyTemplateFromGithubRepoShorthand( + repoShorthand: string, + destPath: string, + options: CopyTemplateOptions +) { + let [owner, name, ...path] = repoShorthand.split("/"); + let filePath = path.length ? path.join("/") : null; + + await downloadAndExtractRepoTarball( + { owner, name, filePath }, + destPath, + options + ); +} + +async function copyTemplateFromGithubRepoUrl( + repoUrl: string, + destPath: string, + options: CopyTemplateOptions +) { + await downloadAndExtractRepoTarball(getRepoInfo(repoUrl), destPath, options); +} + +async function copyTemplateFromGenericUrl( + url: string, + destPath: string, + options: CopyTemplateOptions +) { + await copyTemplateFromRemoteTarball(url, destPath, options); +} + +async function copyTemplateFromLocalFilePath( + filePath: string, + destPath: string +) { + if (filePath.endsWith(".tar.gz")) { + await extractLocalTarball(filePath, destPath); + return; + } + if (fs.statSync(filePath).isDirectory()) { + await fse.copy(filePath, destPath); + return; + } + throw new CopyTemplateError( + "The provided template is not a valid local directory or tarball." + ); +} + +const pipeline = promisify(stream.pipeline); + +async function extractLocalTarball( + tarballPath: string, + destPath: string +): Promise { + try { + await pipeline( + fs.createReadStream(tarballPath), + gunzip(), + tar.extract(destPath, { strip: 1 }) + ); + } catch (error: unknown) { + throw new CopyTemplateError( + "There was a problem extracting the file from the provided template." + + ` Template filepath: \`${tarballPath}\`` + + ` Destination directory: \`${destPath}\`` + + ` ${error}` + ); + } +} + +interface TarballDownloadOptions { + debug?: boolean; + filePath?: string | null; + token?: string; +} + +async function downloadAndExtractRepoTarball( + repo: RepoInfo, + destPath: string, + options: TarballDownloadOptions +) { + // If we have a direct file path we will also have the branch. We can skip the + // redirect and get the tarball URL directly. + if (repo.branch && repo.filePath) { + let tarballURL = `https://codeload.github.com/${repo.owner}/${repo.name}/tar.gz/${repo.branch}`; + return await downloadAndExtractTarball(destPath, tarballURL, { + ...options, + filePath: repo.filePath, + }); + } + + // If we don't know the branch, the GitHub API will figure out the default and + // redirect the request to the tarball. + // https://docs.github.com/en/rest/reference/repos#download-a-repository-archive-tar + let url = `https://api.github.com/repos/${repo.owner}/${repo.name}/tarball`; + if (repo.branch) { + url += `/${repo.branch}`; + } + + return await downloadAndExtractTarball(destPath, url, { + ...options, + filePath: repo.filePath ?? null, + }); +} + +interface DownloadAndExtractTarballOptions { + token?: string; + filePath?: string | null; +} + +async function downloadAndExtractTarball( + downloadPath: string, + tarballUrl: string, + { token, filePath }: DownloadAndExtractTarballOptions +): Promise { + let resourceUrl = tarballUrl; + let headers: HeadersInit = {}; + let isGithubUrl = new URL(tarballUrl).host.endsWith("github.com"); + if (token && isGithubUrl) { + headers.Authorization = `token ${token}`; + } + if (isGithubReleaseAssetUrl(tarballUrl)) { + // We can download the asset via the github api, but first we need to look + // up the asset id + let info = getGithubReleaseAssetInfo(tarballUrl); + headers.Accept = "application/vnd.github.v3+json"; + + let releaseUrl = + info.tag === "latest" + ? `https://api.github.com/repos/${info.owner}/${info.name}/releases/latest` + : `https://api.github.com/repos/${info.owner}/${info.name}/releases/tags/${info.tag}`; + + let response = await fetch(releaseUrl, { + agent: agent("https://api.github.com"), + headers, + }); + + if (response.status !== 200) { + throw new CopyTemplateError( + "There was a problem fetching the file from GitHub. The request " + + `responded with a ${response.status} status. Please try again later.` + ); + } + + let body = (await response.json()) as { assets: GitHubApiReleaseAsset[] }; + if ( + !body || + typeof body !== "object" || + !body.assets || + !Array.isArray(body.assets) + ) { + throw new CopyTemplateError( + "There was a problem fetching the file from GitHub. No asset was " + + "found at that url. Please try again later." + ); + } + + let assetId = body.assets.find((asset) => { + // If the release is "latest", the url won't match the download url + return info.tag === "latest" + ? asset?.browser_download_url?.includes(info.asset) + : asset?.browser_download_url === tarballUrl; + })?.id; + if (assetId == null) { + throw new CopyTemplateError( + "There was a problem fetching the file from GitHub. No asset was " + + "found at that url. Please try again later." + ); + } + resourceUrl = `https://api.github.com/repos/${info.owner}/${info.name}/releases/assets/${assetId}`; + headers.Accept = "application/octet-stream"; + } + let response = await fetch(resourceUrl, { + agent: agent(resourceUrl), + headers, + }); + + if (!response.body || response.status !== 200) { + if (token) { + throw new CopyTemplateError( + `There was a problem fetching the file${ + isGithubUrl ? " from GitHub" : "" + }. The request ` + + `responded with a ${response.status} status. Perhaps your \`--token\`` + + "is expired or invalid." + ); + } + throw new CopyTemplateError( + `There was a problem fetching the file${ + isGithubUrl ? " from GitHub" : "" + }. The request ` + + `responded with a ${response.status} status. Please try again later.` + ); + } + + // file paths returned from github are always unix style + if (filePath) { + filePath = filePath.split(path.sep).join(path.posix.sep); + } + + let filePathHasFiles = false; + + try { + await pipeline( + response.body.pipe(gunzip()), + tar.extract(downloadPath, { + map(header) { + let originalDirName = header.name.split("/")[0]; + header.name = header.name.replace(`${originalDirName}/`, ""); + + if (filePath) { + if (header.name.startsWith(filePath)) { + filePathHasFiles = true; + header.name = header.name.replace(filePath, ""); + } else { + header.name = "__IGNORE__"; + } + } + + return header; + }, + ignore(_filename, header) { + if (!header) { + throw Error("Header is undefined"); + } + return header.name === "__IGNORE__"; + }, + }) + ); + } catch (_) { + throw new CopyTemplateError( + "There was a problem extracting the file from the provided template." + + ` Template URL: \`${tarballUrl}\`` + + ` Destination directory: \`${downloadPath}\`` + ); + } + + if (filePath && !filePathHasFiles) { + throw new CopyTemplateError( + `The path "${filePath}" was not found in this ${ + isGithubUrl ? "GitHub repo." : "tarball." + }` + ); + } +} + +function isValidGithubRepoUrl( + input: string | URL +): input is URL | GithubUrlString { + if (!isUrl(input)) { + return false; + } + try { + let url = new URL(input); + let pathSegments = url.pathname.slice(1).split("/"); + + return ( + url.protocol === "https:" && + url.hostname === "github.com" && + // The pathname must have at least 2 segments. If it has more than 2, the + // third must be "tree" and it must have at least 4 segments. + // https://github.com/:owner/:repo + // https://github.com/:owner/:repo/tree/:ref + pathSegments.length >= 2 && + (pathSegments.length > 2 + ? pathSegments[2] === "tree" && pathSegments.length >= 4 + : true) + ); + } catch (_) { + return false; + } +} + +function isGithubRepoShorthand(value: string) { + // This supports :owner/:repo and :owner/:repo/nested/path, e.g. + // remix-run/remix + // remix-run/remix/templates/express + return /^[\w-]+\/[\w-]+(\/[\w-]+)*$/.test(value); +} + +function isGithubReleaseAssetUrl(url: string) { + /** + * Accounts for the following formats: + * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz + * ~or~ + * https://github.com/owner/repository/releases/latest/download/stack.tar.gz + */ + return ( + url.startsWith("https://github.com") && + (url.includes("/releases/download/") || + url.includes("/releases/latest/download/")) + ); +} + +function getGithubReleaseAssetInfo(browserUrl: string): ReleaseAssetInfo { + /** + * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz + * ~or~ + * https://github.com/owner/repository/releases/latest/download/stack.tar.gz + */ + + let url = new URL(browserUrl); + let [, owner, name, , downloadOrLatest, tag, asset] = url.pathname.split("/"); + + if (downloadOrLatest === "latest" && tag === "download") { + // handle the Github URL quirk for latest releases + tag = "latest"; + } + + return { + browserUrl, + owner, + name, + asset, + tag, + }; +} + +function getRepoInfo(validatedGithubUrl: string): RepoInfo { + let url = new URL(validatedGithubUrl); + let [, owner, name, tree, branch, ...file] = url.pathname.split("/") as [ + _: string, + Owner: string, + Name: string, + Tree: string | undefined, + Branch: string | undefined, + FileInfo: string | undefined + ]; + let filePath = file.join("/"); + + if (tree === undefined) { + return { + owner, + name, + branch: null, + filePath: null, + }; + } + + return { + owner, + name, + // If we've validated the GitHub URL and there is a tree, there will also be + // a branch + branch: branch!, + filePath: filePath === "" || filePath === "/" ? null : filePath, + }; +} + +export class CopyTemplateError extends Error { + constructor(message: string) { + super(message); + this.name = "CopyTemplateError"; + } +} + +interface RepoInfo { + owner: string; + name: string; + branch?: string | null; + filePath?: string | null; +} + +// https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#get-a-release-asset +interface GitHubApiReleaseAsset { + url: string; + browser_download_url: string; + id: number; + node_id: string; + name: string; + label: string; + state: "uploaded" | "open"; + content_type: string; + size: number; + download_count: number; + created_at: string; + updated_at: string; + uploader: null | GitHubApiUploader; +} + +interface GitHubApiUploader { + name: string | null; + email: string | null; + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string | null; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + starred_at: string; +} + +interface ReleaseAssetInfo { + browserUrl: string; + owner: string; + name: string; + asset: string; + tag: string; +} + +type GithubUrlString = + | `https://github.com/${string}/${string}` + | `https://www.github.com/${string}/${string}`; diff --git a/packages/create-remix/create-remix.ts b/packages/create-remix/create-remix.ts new file mode 100644 index 00000000000..83b23879db6 --- /dev/null +++ b/packages/create-remix/create-remix.ts @@ -0,0 +1,819 @@ +import process from "node:process"; +import fs from "node:fs"; +import path from "node:path"; +import stripAnsi from "strip-ansi"; +import rm from "rimraf"; +import execa from "execa"; +import arg from "arg"; +import * as semver from "semver"; +import sortPackageJSON from "sort-package-json"; + +import { version as thisRemixVersion } from "./package.json"; +import { prompt } from "./prompt"; +import { + color, + ensureDirectory, + error, + fileExists, + info, + isInteractive, + isValidJsonObject, + log, + pathContains, + sleep, + strip, + success, + toValidProjectName, +} from "./utils"; +import { renderLoadingIndicator } from "./loading-indicator"; +import { copyTemplate, CopyTemplateError } from "./copy-template"; +import { getLatestRemixVersion } from "./remix-version"; + +async function createRemix(argv: string[]) { + let ctx = await getContext(argv); + if (ctx.help) { + printHelp(ctx); + return; + } + if (ctx.versionRequested) { + log(thisRemixVersion); + return; + } + + let steps = [ + introStep, + projectNameStep, + templateStep, + gitInitQuestionStep, + installDependenciesQuestionStep, + runInitScriptQuestionStep, + installDependenciesStep, + gitInitStep, + runInitScriptStep, + doneStep, + ]; + + try { + for (let step of steps) { + await step(ctx); + } + } catch (err) { + if (ctx.debug) { + console.error(err); + } + throw err; + } +} + +async function getContext(argv: string[]): Promise { + let flags = arg( + { + "--debug": Boolean, + "--remix-version": String, + "-v": "--remix-version", + "--template": String, + "--token": String, + "--yes": Boolean, + "-y": "--yes", + "--install": Boolean, + "--no-install": Boolean, + "--package-manager": String, + "--show-install-output": Boolean, + "--init-script": Boolean, + "--no-init-script": Boolean, + "--git-init": Boolean, + "--no-git-init": Boolean, + "--help": Boolean, + "-h": "--help", + "--version": Boolean, + "--V": "--version", + "--no-color": Boolean, + "--no-motion": Boolean, + }, + { argv, permissive: true } + ); + + let { + "--debug": debug = false, + "--help": help = false, + "--remix-version": selectedRemixVersion, + "--template": template, + "--token": token, + "--install": install, + "--no-install": noInstall, + "--package-manager": pkgManager, + "--show-install-output": showInstallOutput = false, + "--git-init": git, + "--no-init-script": noInitScript, + "--init-script": initScript, + "--no-git-init": noGit, + "--no-motion": noMotion, + "--yes": yes, + "--version": versionRequested, + } = flags; + + let cwd = flags["_"][0] as string; + let latestRemixVersion = await getLatestRemixVersion(); + let interactive = isInteractive(); + let projectName = cwd; + + if (!interactive) { + yes = true; + } + + if (selectedRemixVersion) { + if (semver.valid(selectedRemixVersion)) { + // do nothing, we're good + } else if (semver.coerce(selectedRemixVersion)) { + selectedRemixVersion = semver.coerce(selectedRemixVersion)!.version; + } else { + log( + `\n${color.warning( + `${selectedRemixVersion} is an invalid version specifier. Using Remix v${latestRemixVersion}.` + )}` + ); + selectedRemixVersion = undefined; + } + } + + let context: Context = { + cwd, + interactive, + debug, + git: git ?? (noGit ? false : yes), + initScript: initScript ?? (noInitScript ? false : yes), + initScriptPath: null, + help, + install: install ?? (noInstall ? false : yes), + showInstallOutput, + noMotion, + pkgManager: validatePackageManager( + pkgManager ?? + // npm, pnpm and Yarn set the user agent environment variable that can be used + // to determine which package manager ran the command. + (process.env.npm_config_user_agent ?? "npm").split("/")[0] + ), + projectName, + prompt, + remixVersion: selectedRemixVersion || latestRemixVersion, + template, + token, + versionRequested, + }; + + return context; +} + +interface Context { + cwd: string; + interactive: boolean; + debug: boolean; + git?: boolean; + initScript?: boolean; + initScriptPath: null | string; + help: boolean; + install?: boolean; + showInstallOutput: boolean; + noMotion?: boolean; + pkgManager: PackageManager; + projectName?: string; + prompt: typeof prompt; + remixVersion: string; + stdin?: typeof process.stdin; + stdout?: typeof process.stdout; + template?: string; + token?: string; + versionRequested?: boolean; +} + +async function introStep(ctx: Context) { + log( + `\n${color.bgWhite(` ${color.whiteBright("remix")} `)} ${color.green( + color.bold(`v${ctx.remixVersion}`) + )} ${color.bold("💿 Let's build a better website...")}` + ); + + if (!ctx.interactive) { + log(""); + info("Shell is not interactive.", [ + `Using default options. This is equivalent to running with the `, + color.reset("--yes"), + ` flag.`, + ]); + } +} + +async function projectNameStep(ctx: Context) { + let cwdIsEmpty = ctx.cwd && isEmpty(ctx.cwd); + + // valid cwd is required if shell isn't interactive + if (!ctx.interactive) { + if (!ctx.cwd) { + error("Oh no!", "No project directory provided"); + throw new Error("No project directory provided"); + } + + if (!cwdIsEmpty) { + error( + "Oh no!", + `Project directory "${color.reset(ctx.cwd)}" is not empty` + ); + throw new Error("Project directory is not empty"); + } + } + + if (ctx.cwd) { + await sleep(100); + + if (cwdIsEmpty) { + info("Directory:", [ + "Using ", + color.reset(ctx.cwd), + " as project directory", + ]); + } else { + info("Hmm...", [color.reset(`"${ctx.cwd}"`), " is not empty!"]); + } + } + + if (!ctx.cwd || !cwdIsEmpty) { + let { name } = await ctx.prompt({ + name: "name", + type: "text", + label: title("dir"), + message: "Where should we create your new project?", + initial: "./my-remix-app", + validate(value: string) { + if (!isEmpty(value)) { + return `Directory is not empty!`; + } + return true; + }, + }); + ctx.cwd = name!; + ctx.projectName = toValidProjectName(name!); + return; + } + + let name = ctx.cwd; + if (name === "." || name === "./") { + let parts = process.cwd().split(path.sep); + name = parts[parts.length - 1]; + } else if (name.startsWith("./") || name.startsWith("../")) { + let parts = name.split("/"); + name = parts[parts.length - 1]; + } + ctx.projectName = toValidProjectName(name); +} + +async function templateStep(ctx: Context) { + if (ctx.template) { + log(""); + info("Template", ["Using ", color.reset(ctx.template), "..."]); + } else { + log(""); + info("Using basic template", [ + "See https://remix.run/docs/pages/templates for more", + ]); + } + + let template = + ctx.template ?? + "https://github.com/remix-run/remix/tree/main/templates/remix"; + + await loadingIndicator({ + start: "Template copying...", + end: "Template copied", + while: async () => { + let destPath = path.resolve(process.cwd(), ctx.cwd); + await ensureDirectory(destPath); + await copyTemplate(template, destPath, { + debug: ctx.debug, + token: ctx.token, + async onError(err) { + let cwd = process.cwd(); + let removing = (async () => { + if (cwd !== destPath && !pathContains(cwd, destPath)) { + try { + await rm(destPath); + } catch (_) { + error("Oh no!", ["Failed to remove ", destPath]); + } + } + })(); + if (ctx.debug) { + try { + await removing; + } catch (_) {} + throw err; + } + + await Promise.all([ + error( + "Oh no!", + err instanceof CopyTemplateError + ? err.message + : "Something went wrong. Run `create-remix --debug` to see more info.\n\n" + + "Open an issue to report the problem at " + + "https://github.com/remix-run/remix/issues/new" + ), + removing, + ]); + + throw err; + }, + async log(message) { + if (ctx.debug) { + info(message); + await sleep(500); + } + }, + }); + + await updatePackageJSON(ctx); + }, + ctx, + }); + + ctx.initScriptPath = await getInitScriptPath(ctx.cwd); +} + +async function installDependenciesQuestionStep(ctx: Context) { + if (ctx.install === undefined) { + let { deps = true } = await ctx.prompt({ + name: "deps", + type: "confirm", + label: title("deps"), + message: `Install dependencies with ${ctx.pkgManager}?`, + hint: "recommended", + initial: true, + }); + ctx.install = deps; + } +} + +async function runInitScriptQuestionStep(ctx: Context) { + if (!ctx.initScriptPath) { + return; + } + + // We can't run the init script without installing dependencies + if (!ctx.install) { + return; + } + + if (ctx.initScript === undefined) { + let { init } = await ctx.prompt({ + name: "init", + type: "confirm", + label: title("init"), + message: `This template has a remix.init script. Do you want to run it?`, + hint: "recommended", + initial: true, + }); + + ctx.initScript = init; + } +} + +async function installDependenciesStep(ctx: Context) { + let { install, pkgManager, showInstallOutput, cwd } = ctx; + + if (!install) { + await sleep(100); + info("Skipping install step.", [ + "Remember to install dependencies after setup with ", + color.reset(`${pkgManager} install`), + ".", + ]); + return; + } + + function runInstall() { + return installDependencies({ + cwd, + pkgManager, + showInstallOutput, + }); + } + + if (showInstallOutput) { + log(""); + info(`Install`, `Dependencies installing with ${pkgManager}...`); + log(""); + await runInstall(); + log(""); + return; + } + + log(""); + await loadingIndicator({ + start: `Dependencies installing with ${pkgManager}...`, + end: "Dependencies installed", + while: runInstall, + ctx, + }); +} + +async function gitInitQuestionStep(ctx: Context) { + if (fs.existsSync(path.join(ctx.cwd, ".git"))) { + info("Nice!", `Git has already been initialized`); + return; + } + + let git = ctx.git; + if (ctx.git === undefined) { + ({ git } = await ctx.prompt({ + name: "git", + type: "confirm", + label: title("git"), + message: `Initialize a new git repository?`, + hint: "recommended", + initial: true, + })); + } + + ctx.git = git ?? false; +} + +async function gitInitStep(ctx: Context) { + if (!ctx.git) { + return; + } + + if (fs.existsSync(path.join(ctx.cwd, ".git"))) { + log(""); + info("Nice!", `Git has already been initialized`); + return; + } + + log(""); + await loadingIndicator({ + start: "Git initializing...", + end: "Git initialized", + while: async () => { + let options = { cwd: ctx.cwd, stdio: "ignore" } as const; + let commitMsg = "Initial commit from create-remix"; + try { + await execa("git", ["init"], options); + await execa("git", ["add", "."], options); + await execa("git", ["commit", "-m", commitMsg], options); + } catch (err) { + error("Oh no!", "Failed to initialize git."); + throw err; + } + }, + ctx, + }); +} + +async function runInitScriptStep(ctx: Context) { + if (!ctx.initScriptPath) { + return; + } + + let initCommand = `${packageManagerExecScript[ctx.pkgManager]} remix init`; + + if (!ctx.install || !ctx.initScript) { + await sleep(100); + log(""); + info("Skipping template's remix.init script.", [ + ctx.install + ? "You can run the script in the " + : "After installing dependencies, you can run the script in the ", + color.reset("remix.init"), + " directory with ", + color.reset(initCommand), + ".", + ]); + return; + } + + let initScriptDir = path.dirname(ctx.initScriptPath); + let initPackageJson = path.resolve(initScriptDir, "package.json"); + let isTypeScript = fs.existsSync(path.join(ctx.cwd, "tsconfig.json")); + let packageManager = ctx.pkgManager; + + try { + if (await fileExists(initPackageJson)) { + await loadingIndicator({ + start: `Dependencies for remix.init script installing with ${ctx.pkgManager}...`, + end: "Dependencies for remix.init script installed", + while: () => + installDependencies({ + pkgManager: ctx.pkgManager, + cwd: initScriptDir, + showInstallOutput: ctx.showInstallOutput, + }), + ctx, + }); + } + } catch (err) { + error("Oh no!", "Failed to install dependencies for template init script"); + throw err; + } + + log(""); + info("Running template's remix.init script...", "\n"); + + try { + let initFn = require(ctx.initScriptPath); + if (typeof initFn !== "function" && initFn.default) { + initFn = initFn.default; + } + if (typeof initFn !== "function") { + throw new Error("remix.init script doesn't export a function."); + } + let rootDirectory = path.resolve(ctx.cwd); + await initFn({ isTypeScript, packageManager, rootDirectory }); + } catch (err) { + error("Oh no!", "Template's remix.init script failed"); + throw err; + } + + try { + await rm(initScriptDir); + } catch (err) { + error("Oh no!", "Failed to remove template's remix.init script"); + throw err; + } + + log(""); + success("Template's remix.init script complete"); + + if (ctx.git) { + await loadingIndicator({ + start: "Committing changes from remix.init script...", + end: "Committed changes from remix.init script", + while: async () => { + let options = { cwd: ctx.cwd, stdio: "ignore" } as const; + let commitMsg = "Initialize project with remix.init script"; + try { + await execa("git", ["add", "."], options); + await execa("git", ["commit", "-m", commitMsg], options); + } catch (err) { + error("Oh no!", "Failed to commit changes from remix.init script."); + throw err; + } + }, + ctx, + }); + } +} + +async function doneStep(ctx: Context) { + let projectDir = path.relative(process.cwd(), ctx.cwd); + + let max = process.stdout.columns; + let prefix = max < 80 ? " " : " ".repeat(9); + await sleep(200); + + log(`\n ${color.bgWhite(color.black(" done "))} That's it!`); + await sleep(100); + if (projectDir !== "") { + let enter = [ + `\n${prefix}Enter your project directory using`, + color.cyan(`cd ./${projectDir}`), + ]; + let len = enter[0].length + stripAnsi(enter[1]).length; + log(enter.join(len > max ? "\n" + prefix : " ")); + } + log( + `${prefix}Check out ${color.bold( + "README.md" + )} for development and deploy instructions.` + ); + await sleep(100); + log( + `\n${prefix}Join the community at ${color.cyan(`https://rmx.as/discord`)}\n` + ); + await sleep(200); +} + +function isEmpty(dirPath: string) { + if (!fs.existsSync(dirPath)) { + return true; + } + + // Some existing files can be safely ignored when checking if + // a directory is a valid project directory. + let VALID_PROJECT_DIRECTORY_SAFE_LIST = [".DS_Store", "Thumbs.db"]; + + let conflicts = fs.readdirSync(dirPath).filter((content) => { + return !VALID_PROJECT_DIRECTORY_SAFE_LIST.some((safeContent) => { + return content === safeContent; + }); + }); + return conflicts.length === 0; +} + +type PackageManager = "npm" | "yarn" | "pnpm"; + +const packageManagerExecScript: Record = { + npm: "npx", + yarn: "yarn", + pnpm: "pnpm exec", +}; + +function validatePackageManager(pkgManager: string): PackageManager { + return packageManagerExecScript.hasOwnProperty(pkgManager) + ? (pkgManager as PackageManager) + : "npm"; +} + +async function installDependencies({ + pkgManager, + cwd, + showInstallOutput, +}: { + pkgManager: PackageManager; + cwd: string; + showInstallOutput: boolean; +}) { + try { + await execa(pkgManager, ["install"], { + cwd, + stdio: showInstallOutput ? "inherit" : "ignore", + }); + } catch (err) { + error("Oh no!", "Failed to install dependencies."); + throw err; + } +} + +async function updatePackageJSON(ctx: Context) { + let packageJSONPath = path.join(ctx.cwd, "package.json"); + if (!fs.existsSync(packageJSONPath)) { + let relativePath = path.relative(process.cwd(), ctx.cwd); + error( + "Oh no!", + "The provided template must be a Remix project with a `package.json` " + + `file, but that file does not exist in ${color.bold(relativePath)}.` + ); + throw new Error(`package.json does not exist in ${ctx.cwd}`); + } + + let contents = await fs.promises.readFile(packageJSONPath, "utf-8"); + let packageJSON: any; + try { + packageJSON = JSON.parse(contents); + if (!isValidJsonObject(packageJSON)) { + throw Error(); + } + } catch (err) { + error( + "Oh no!", + "The provided template must be a Remix project with a `package.json` " + + `file, but that file is invalid.` + ); + throw err; + } + + for (let pkgKey of ["dependencies", "devDependencies"] as const) { + let dependencies = packageJSON[pkgKey]; + if (!dependencies) continue; + + if (!isValidJsonObject(dependencies)) { + error( + "Oh no!", + "The provided template must be a Remix project with a `package.json` " + + `file, but its ${pkgKey} value is invalid.` + ); + throw new Error(`package.json ${pkgKey} are invalid`); + } + + for (let dependency in dependencies) { + let version = dependencies[dependency]; + if (version === "*") { + dependencies[dependency] = semver.prerelease(ctx.remixVersion) + ? // Templates created from prereleases should pin to a specific version + ctx.remixVersion + : "^" + ctx.remixVersion; + } + } + } + + if (!ctx.initScriptPath) { + packageJSON.name = ctx.projectName; + } + + fs.promises.writeFile( + packageJSONPath, + JSON.stringify(sortPackageJSON(packageJSON), null, 2), + "utf-8" + ); +} + +async function loadingIndicator(args: { + start: string; + end: string; + while: (...args: any) => Promise; + ctx: Context; +}) { + let { ctx, ...rest } = args; + await renderLoadingIndicator({ + ...rest, + noMotion: args.ctx.noMotion, + }); +} + +function title(text: string) { + return align(color.bgWhite(` ${color.whiteBright(text)} `), "end", 7) + " "; +} + +function printHelp(ctx: Context) { + // prettier-ignore + let output = ` +${title("create-remix")} + +${color.heading("Usage")}: + +${color.dim("$")} ${color.greenBright("create-remix")} ${color.arg("")} ${color.arg("<...options>")} + +${color.heading("Values")}: + +${color.arg("projectDir")} ${color.dim(`The Remix project directory`)} + +${color.heading("Options")}: + +${color.arg("--help, -h")} ${color.dim(`Print this help message and exit`)} +${color.arg("--version, -V")} ${color.dim(`Print the CLI version and exit`)} +${color.arg("--no-color")} ${color.dim(`Disable ANSI colors in console output`)} +${color.arg("--no-motion")} ${color.dim(`Disable animations in console output`)} + +${color.arg("--template ")} ${color.dim(`The project template to use`)} +${color.arg("--[no-]install")} ${color.dim(`Whether or not to install dependencies after creation`)} +${color.arg("--package-manager")} ${color.dim(`The package manager to use`)} +${color.arg("--show-install-output")} ${color.dim(`Whether to show the output of the install process`)} +${color.arg("--[no-]init-script")} ${color.dim(`Whether or not to run the template's remix.init script, if present`)} +${color.arg("--[no-]git-init")} ${color.dim(`Whether or not to initialize a Git repository`)} +${color.arg("--yes, -y")} ${color.dim(`Skip all option prompts and run setup`)} +${color.arg("--remix-version, -v")} ${color.dim(`The version of Remix to use`)} + +${color.heading("Creating a new project")}: + +Remix projects are created from templates. A template can be: + +- a GitHub repo shorthand, :username/:repo or :username/:repo/:directory +- the URL of a GitHub repo (or directory within it) +- the URL of a tarball +- a file path to a directory of files +- a file path to a tarball +${[ + "remix-run/grunge-stack", + "remix-run/remix/templates/remix", + "remix-run/examples/basic", + ":username/:repo", + ":username/:repo/:directory", + "https://github.com/:username/:repo", + "https://github.com/:username/:repo/tree/:branch", + "https://github.com/:username/:repo/tree/:branch/:directory", + "https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz", + "https://example.com/remix-template.tar.gz", + "./path/to/remix-template", + "./path/to/remix-template.tar.gz", +].reduce((str, example) => { + return `${str}\n${color.dim("$")} ${color.greenBright("create-remix")} my-app ${color.arg(`--template ${example}`)}`; +}, "")} + +To create a new project from a template in a private GitHub repo, +pass the \`token\` flag with a personal access token with access +to that repo. + +${color.heading("Initialize a project")}: + +Remix project templates may contain a \`remix.init\` directory +with a script that initializes the project. This script automatically +runs during \`remix create\`, but if you ever need to run it manually +you can run: + +${color.dim("$")} ${color.greenBright("remix")} init +`; + + log(output); +} + +function align(text: string, dir: "start" | "end" | "center", len: number) { + let pad = Math.max(len - strip(text).length, 0); + switch (dir) { + case "start": + return text + " ".repeat(pad); + case "end": + return " ".repeat(pad) + text; + case "center": + return ( + " ".repeat(Math.floor(pad / 2)) + text + " ".repeat(Math.floor(pad / 2)) + ); + default: + return text; + } +} + +async function getInitScriptPath(cwd: string) { + let initScriptDir = path.join(cwd, "remix.init"); + let initScriptPath = path.resolve(initScriptDir, "index.js"); + return (await fileExists(initScriptPath)) ? initScriptPath : null; +} + +export { createRemix }; +export type { Context }; diff --git a/packages/create-remix/jest.config.js b/packages/create-remix/jest.config.js new file mode 100644 index 00000000000..bd8918937b0 --- /dev/null +++ b/packages/create-remix/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "dev", + setupFilesAfterEnv: ["/__tests__/setupAfterEnv.ts"], + setupFiles: [], +}; diff --git a/packages/create-remix/loading-indicator.ts b/packages/create-remix/loading-indicator.ts new file mode 100644 index 00000000000..63254db6cd9 --- /dev/null +++ b/packages/create-remix/loading-indicator.ts @@ -0,0 +1,175 @@ +// Adapted from https://github.com/withastro/cli-kit +// MIT License Copyright (c) 2022 Nate Moore +import process from "node:process"; +import readline from "node:readline"; +import { erase, cursor } from "sisteransi"; + +import { reverse, sleep, color } from "./utils"; + +const GRADIENT_COLORS: Array<`#${string}`> = [ + "#ffffff", + "#dadada", + "#dadada", + "#a8deaa", + "#a8deaa", + "#a8deaa", + "#d0f0bd", + "#d0f0bd", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#ffffed", + "#f7f8ca", + "#f7f8ca", + "#eae6ba", + "#eae6ba", + "#eae6ba", + "#dadada", + "#dadada", + "#ffffff", +]; + +const MAX_FRAMES = 8; + +const LEADING_FRAMES = Array.from( + { length: MAX_FRAMES * 2 }, + () => GRADIENT_COLORS[0] +); +const TRAILING_FRAMES = Array.from( + { length: MAX_FRAMES * 2 }, + () => GRADIENT_COLORS[GRADIENT_COLORS.length - 1] +); +const INDICATOR_FULL_FRAMES = [ + ...LEADING_FRAMES, + ...GRADIENT_COLORS, + ...TRAILING_FRAMES, + ...reverse(GRADIENT_COLORS), +]; +const INDICATOR_GRADIENT = reverse( + INDICATOR_FULL_FRAMES.map((_, i) => loadingIndicatorFrame(i)) +); + +export async function renderLoadingIndicator({ + start, + end, + while: update = () => sleep(100), + noMotion = false, + stdin = process.stdin, + stdout = process.stdout, +}: { + start: string; + end: string; + while: (...args: any) => Promise; + noMotion?: boolean; + stdin?: NodeJS.ReadStream & { fd: 0 }; + stdout?: NodeJS.WriteStream & { fd: 1 }; +}) { + let act = update(); + let tooSlow = Object.create(null); + let result = await Promise.race([sleep(500).then(() => tooSlow), act]); + if (result === tooSlow) { + let loading = await gradient(color.green(start), { + stdin, + stdout, + noMotion, + }); + await act; + loading.stop(); + } + stdout.write(`${" ".repeat(5)} ${color.green("✔")} ${color.green(end)}\n`); +} + +function loadingIndicatorFrame(offset = 0) { + let frames = INDICATOR_FULL_FRAMES.slice(offset, offset + (MAX_FRAMES - 2)); + if (frames.length < MAX_FRAMES - 2) { + let filled = new Array(MAX_FRAMES - frames.length - 2).fill( + GRADIENT_COLORS[0] + ); + frames.push(...filled); + } + return frames; +} + +function getGradientAnimationFrames() { + return INDICATOR_GRADIENT.map( + (colors) => " " + colors.map((g, i) => color.hex(g)("█")).join("") + ); +} + +async function gradient( + text: string, + { stdin = process.stdin, stdout = process.stdout, noMotion = false } = {} +) { + let { createLogUpdate } = await import("log-update"); + let logUpdate = createLogUpdate(stdout); + let frameIndex = 0; + let frames = getGradientAnimationFrames(); + let interval: NodeJS.Timeout; + let rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 50 }); + readline.emitKeypressEvents(stdin, rl); + + if (stdin.isTTY) stdin.setRawMode(true); + function keypress(char: string) { + if (char === "\x03") { + loadingIndicator.stop(); + process.exit(0); + } + if (stdin.isTTY) stdin.setRawMode(true); + stdout.write(cursor.hide + erase.lines(1)); + } + + let done = false; + let loadingIndicator = { + start() { + stdout.write(cursor.hide); + stdin.on("keypress", keypress); + logUpdate(`${frames[0]} ${text}`); + + async function loop() { + if (done) return; + if (frameIndex < frames.length - 1) { + frameIndex++; + } else { + frameIndex = 0; + } + let frame = frames[frameIndex]; + logUpdate( + `${(noMotion + ? getMotionlessFrame(frameIndex) + : color.supportsColor + ? frame + : getColorlessFrame(frameIndex) + ).padEnd(MAX_FRAMES - 1, " ")} ${text}` + ); + if (!done) await sleep(20); + loop(); + } + + loop(); + }, + stop() { + done = true; + stdin.removeListener("keypress", keypress); + clearInterval(interval); + logUpdate.clear(); + rl.close(); + }, + }; + loadingIndicator.start(); + return loadingIndicator; +} + +function getColorlessFrame(frameIndex: number) { + return ( + frameIndex % 3 === 0 ? ".. .. " : frameIndex % 3 === 1 ? " .. .." : ". .. ." + ).padEnd(MAX_FRAMES - 1 + 20, " "); +} + +function getMotionlessFrame(frameIndex: number) { + return " ".repeat(MAX_FRAMES - 1); +} diff --git a/packages/create-remix/package.json b/packages/create-remix/package.json index 26d254941fb..9ad2db7711a 100644 --- a/packages/create-remix/package.json +++ b/packages/create-remix/package.json @@ -17,7 +17,28 @@ "create-remix": "dist/cli.js" }, "dependencies": { - "@remix-run/dev": "1.19.0" + "arg": "^5.0.1", + "chalk": "^4.1.2", + "execa": "5.1.1", + "gunzip-maybe": "^1.4.2", + "log-update": "^5.0.1", + "node-fetch": "^2.6.9", + "proxy-agent": "^6.3.0", + "rimraf": "^4.1.2", + "semver": "^7.3.7", + "sisteransi": "^1.0.5", + "sort-package-json": "^1.55.0", + "strip-ansi": "^6.0.1", + "tar-fs": "^2.1.1" + }, + "devDependencies": { + "@types/gunzip-maybe": "^1.4.0", + "@types/node-fetch": "^2.5.7", + "@types/tar-fs": "^2.0.1", + "fs-extra": "^10.0.0", + "msw": "^0.39.2", + "tiny-invariant": "^1.2.0", + "esbuild-register": "^3.3.2" }, "engines": { "node": ">=14.0.0" diff --git a/packages/create-remix/prompt.ts b/packages/create-remix/prompt.ts new file mode 100644 index 00000000000..716ff7ea8de --- /dev/null +++ b/packages/create-remix/prompt.ts @@ -0,0 +1,193 @@ +// Adapted from https://github.com/withastro/cli-kit +// MIT License Copyright (c) 2022 Nate Moore +// https://github.com/withastro/cli-kit/tree/main/src/prompt +import process from "node:process"; + +import { ConfirmPrompt, type ConfirmPromptOptions } from "./prompts-confirm"; +import { + SelectPrompt, + type SelectPromptOptions, + type SelectChoice, +} from "./prompts-select"; +import { + MultiSelectPrompt, + type MultiSelectPromptOptions, +} from "./prompts-multi-select"; +import { TextPrompt, type TextPromptOptions } from "./prompts-text"; +import { identity } from "./utils"; + +const prompts = { + text: (args: TextPromptOptions) => toPrompt(TextPrompt, args), + confirm: (args: ConfirmPromptOptions) => toPrompt(ConfirmPrompt, args), + select: []>( + args: SelectPromptOptions + ) => toPrompt(SelectPrompt, args), + multiselect: []>( + args: MultiSelectPromptOptions + ) => toPrompt(MultiSelectPrompt, args), +}; + +export async function prompt< + T extends Readonly> | Readonly[]>, + P extends T extends Readonly ? T[number] : T = T extends Readonly< + any[] + > + ? T[number] + : T +>(questions: T, opts: PromptTypeOptions

= {}): Promise> { + let { + onSubmit = identity, + onCancel = () => process.exit(0), + stdin = process.stdin, + stdout = process.stdout, + } = opts; + + let answers = {} as Answers; + + let questionsArray = ( + Array.isArray(questions) ? questions : [questions] + ) as Readonly; + let answer: Answer

; + let quit: any; + let name: string; + let type: P["type"]; + + for (let question of questionsArray) { + ({ name, type } = question); + + try { + // Get the injected answer if there is one or prompt the user + // @ts-expect-error + answer = await prompts[type](Object.assign({ stdin, stdout }, question)); + answers[name] = answer as any; + quit = await onSubmit(question, answer, answers); + } catch (err) { + quit = !(await onCancel(question, answers)); + } + if (quit) { + return answers; + } + } + return answers; +} + +function toPrompt< + T extends + | typeof TextPrompt + | typeof ConfirmPrompt + | typeof SelectPrompt + | typeof MultiSelectPrompt +>(el: T, args: any, opts: any = {}) { + if ( + el !== TextPrompt && + el !== ConfirmPrompt && + el !== SelectPrompt && + el !== MultiSelectPrompt + ) { + throw new Error(`Invalid prompt type: ${el.name}`); + } + + return new Promise((res, rej) => { + let p = new el( + args, + // @ts-expect-error + opts + ); + let onAbort = args.onAbort || opts.onAbort || identity; + let onSubmit = args.onSubmit || opts.onSubmit || identity; + let onExit = args.onExit || opts.onExit || identity; + p.on("state", args.onState || identity); + p.on("submit", (x: any) => res(onSubmit(x))); + p.on("exit", (x: any) => res(onExit(x))); + p.on("abort", (x: any) => rej(onAbort(x))); + }); +} + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never; + +interface BasePromptType { + name: string; +} + +interface TextPromptType extends BasePromptType { + type: "text"; +} + +interface ConfirmPromptType extends BasePromptType { + type: "confirm"; +} + +interface SelectPromptType< + Choices extends Readonly[]> +> extends BasePromptType { + type: "select"; + choices: Choices; +} + +interface MultiSelectPromptType< + Choices extends Readonly[]> +> extends BasePromptType { + type: "multiselect"; + choices: Choices; +} + +interface SelectChoiceType { + value: unknown; + label: string; + hint?: string; +} + +type PromptType< + Choices extends Readonly = Readonly +> = + | TextPromptType + | ConfirmPromptType + | SelectPromptType + | MultiSelectPromptType; + +type PromptChoices> = T extends SelectPromptType< + infer Choices +> + ? Choices + : T extends MultiSelectPromptType + ? Choices + : never; + +type Answer< + T extends PromptType, + Choices extends Readonly = PromptChoices +> = T extends TextPromptType + ? string + : T extends ConfirmPromptType + ? boolean + : T extends SelectPromptType + ? Choices[number]["value"] + : T extends MultiSelectPromptType + ? (Choices[number]["value"] | undefined)[] + : never; + +type Answers< + T extends Readonly> | Readonly[]> +> = T extends Readonly> + ? Partial<{ [key in T["name"]]: Answer }> + : T extends Readonly[]> + ? UnionToIntersection> + : never; + +interface PromptTypeOptions< + T extends PromptType, + Choices extends Readonly = PromptChoices +> { + onSubmit?( + question: T | Readonly, + answer: Answer, + answers: Answers + ): any; + onCancel?(question: T | Readonly, answers: Answers): any; + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; +} diff --git a/packages/create-remix/prompts-confirm.ts b/packages/create-remix/prompts-confirm.ts new file mode 100644 index 00000000000..593ae29683f --- /dev/null +++ b/packages/create-remix/prompts-confirm.ts @@ -0,0 +1,177 @@ +/** + * Adapted from https://github.com/withastro/cli-kit + * @license MIT License Copyright (c) 2022 Nate Moore + */ +import { cursor, erase } from "sisteransi"; + +import { Prompt, type PromptOptions } from "./prompts-prompt-base"; +import { color, strip, clear, type ActionKey } from "./utils"; + +export interface ConfirmPromptOptions extends PromptOptions { + label: string; + message: string; + initial?: boolean; + hint?: string; + validate?: (v: any) => boolean; + error?: string; +} + +export type ConfirmPromptChoices = [ + { value: true; label: string }, + { value: false; label: string } +]; + +export class ConfirmPrompt extends Prompt { + label: string; + msg: string; + value: boolean | undefined; + initialValue: boolean; + hint?: string; + choices: ConfirmPromptChoices; + cursor: number; + done: boolean | undefined; + name = "ConfirmPrompt" as const; + + // set by render which is called in constructor + outputText!: string; + + constructor(opts: ConfirmPromptOptions) { + super(opts); + this.label = opts.label; + this.hint = opts.hint; + this.msg = opts.message; + this.value = opts.initial; + this.initialValue = !!opts.initial; + this.choices = [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + ]; + this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); + this.render(); + } + + get type() { + return "confirm" as const; + } + + exit() { + this.abort(); + } + + abort() { + this.done = this.aborted = true; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + submit() { + this.value = this.value || false; + this.cursor = this.choices.findIndex((c) => c.value === this.value); + this.done = true; + this.aborted = false; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + moveCursor(n: number) { + this.cursor = n; + this.value = this.choices[n].value; + this.fire(); + } + + reset() { + this.moveCursor(0); + this.fire(); + this.render(); + } + + first() { + this.moveCursor(0); + this.render(); + } + + last() { + this.moveCursor(this.choices.length - 1); + this.render(); + } + + left() { + if (this.cursor === 0) { + this.moveCursor(this.choices.length - 1); + } else { + this.moveCursor(this.cursor - 1); + } + this.render(); + } + + right() { + if (this.cursor === this.choices.length - 1) { + this.moveCursor(0); + } else { + this.moveCursor(this.cursor + 1); + } + this.render(); + } + + _(c: string, key: ActionKey) { + if (!Number.isNaN(Number.parseInt(c))) { + let n = Number.parseInt(c) - 1; + this.moveCursor(n); + this.render(); + return this.submit(); + } + if (c.toLowerCase() === "y") { + this.value = true; + return this.submit(); + } + if (c.toLowerCase() === "n") { + this.value = false; + return this.submit(); + } + return; + } + + render() { + if (this.closed) { + return; + } + if (this.firstRender) { + this.out.write(cursor.hide); + } else { + this.out.write(clear(this.outputText, this.out.columns)); + } + super.render(); + let outputText = [ + "\n", + this.label, + " ", + this.msg, + this.done ? "" : this.hint ? color.dim(` (${this.hint})`) : "", + "\n", + ]; + + outputText.push(" ".repeat(strip(this.label).length)); + + if (this.done) { + outputText.push(" ", color.dim(`${this.choices[this.cursor].label}`)); + } else { + outputText.push( + " ", + this.choices + .map((choice, i) => + i === this.cursor + ? `${color.green("●")} ${choice.label} ` + : color.dim(`○ ${choice.label} `) + ) + .join(color.dim(" ")) + ); + } + this.outputText = outputText.join(""); + + this.out.write(erase.line + cursor.to(0) + this.outputText); + } +} diff --git a/packages/create-remix/prompts-multi-select.ts b/packages/create-remix/prompts-multi-select.ts new file mode 100644 index 00000000000..64b88995d42 --- /dev/null +++ b/packages/create-remix/prompts-multi-select.ts @@ -0,0 +1,194 @@ +/** + * Adapted from https://github.com/withastro/cli-kit + * @license MIT License Copyright (c) 2022 Nate Moore + */ +import { cursor, erase } from "sisteransi"; + +import { Prompt, type PromptOptions } from "./prompts-prompt-base"; +import { type SelectChoice } from "./prompts-select"; +import { color, strip, clear, type ActionKey } from "./utils"; + +export interface MultiSelectPromptOptions< + Choices extends Readonly[]> +> extends PromptOptions { + hint?: string; + message: string; + label: string; + initial?: Choices[number]["value"]; + validate?: (v: any) => boolean; + error?: string; + choices: Choices; +} + +export class MultiSelectPrompt< + Choices extends Readonly[]> +> extends Prompt { + choices: Readonly>; + label: string; + msg: string; + hint?: string; + value: Array; + initialValue: Choices[number]["value"]; + done: boolean | undefined; + cursor: number; + name = "MultiSelectPrompt" as const; + + // set by render which is called in constructor + outputText!: string; + + constructor(opts: MultiSelectPromptOptions) { + if ( + !opts.choices || + !Array.isArray(opts.choices) || + opts.choices.length < 1 + ) { + throw new Error("MultiSelectPrompt must contain choices"); + } + super(opts); + this.label = opts.label; + this.msg = opts.message; + this.hint = opts.hint; + this.value = []; + this.choices = + opts.choices.map((choice) => ({ ...choice, selected: false })) || []; + this.initialValue = opts.initial || this.choices[0].value; + this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); + this.render(); + } + + get type() { + return "multiselect" as const; + } + + exit() { + this.abort(); + } + + abort() { + this.done = this.aborted = true; + this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + submit() { + return this.toggle(); + } + + finish() { + // eslint-disable-next-line no-self-assign + this.value = this.value; + this.done = true; + this.aborted = false; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + moveCursor(n: number) { + this.cursor = n; + this.fire(); + } + + toggle() { + let choice = this.choices[this.cursor]; + if (!choice) return; + choice.selected = !choice.selected; + this.render(); + } + + _(c: string, key: ActionKey) { + if (c === " ") { + return this.toggle(); + } + if (c.toLowerCase() === "c") { + return this.finish(); + } + return; + } + + reset() { + this.moveCursor(0); + this.fire(); + this.render(); + } + + first() { + this.moveCursor(0); + this.render(); + } + + last() { + this.moveCursor(this.choices.length - 1); + this.render(); + } + + up() { + if (this.cursor === 0) { + this.moveCursor(this.choices.length - 1); + } else { + this.moveCursor(this.cursor - 1); + } + this.render(); + } + + down() { + if (this.cursor === this.choices.length - 1) { + this.moveCursor(0); + } else { + this.moveCursor(this.cursor + 1); + } + this.render(); + } + + render() { + if (this.closed) return; + if (this.firstRender) { + this.out.write(cursor.hide); + } else { + this.out.write(clear(this.outputText, this.out.columns)); + } + super.render(); + + let outputText = ["\n", this.label, " ", this.msg, "\n"]; + + let prefix = " ".repeat(strip(this.label).length); + + if (this.done) { + outputText.push( + this.choices + .map((choice) => + choice.selected ? `${prefix} ${color.dim(`${choice.label}`)}\n` : "" + ) + .join("") + .trimEnd() + ); + } else { + outputText.push( + this.choices + .map((choice, i) => + i === this.cursor + ? `${prefix.slice(0, -2)}${color.cyanBright("▶")} ${ + choice.selected ? color.green("■") : color.whiteBright("□") + } ${color.underline(choice.label)} ${ + choice.hint ? color.dim(choice.hint) : "" + }` + : color[choice.selected ? "reset" : "dim"]( + `${prefix} ${choice.selected ? color.green("■") : "□"} ${ + choice.label + } ` + ) + ) + .join("\n") + ); + outputText.push( + `\n\n${prefix} Press ${color.inverse(" C ")} to continue` + ); + } + this.outputText = outputText.join(""); + this.out.write(erase.line + cursor.to(0) + this.outputText); + } +} diff --git a/packages/create-remix/prompts-prompt-base.ts b/packages/create-remix/prompts-prompt-base.ts new file mode 100644 index 00000000000..1f0823576e5 --- /dev/null +++ b/packages/create-remix/prompts-prompt-base.ts @@ -0,0 +1,115 @@ +/** + * Adapted from https://github.com/withastro/cli-kit + * @license MIT License Copyright (c) 2022 Nate Moore + */ +import process from "node:process"; +import EventEmitter from "node:events"; +import readline from "node:readline"; +import { beep, cursor } from "sisteransi"; + +import { color, action, type ActionKey } from "./utils"; + +export class Prompt extends EventEmitter { + firstRender: boolean; + in: any; + out: any; + onRender: any; + close: () => void; + aborted: any; + exited: any; + closed: boolean | undefined; + name = "Prompt"; + + constructor(opts: PromptOptions = {}) { + super(); + this.firstRender = true; + this.in = opts.stdin || process.stdin; + this.out = opts.stdout || process.stdout; + this.onRender = (opts.onRender || (() => void 0)).bind(this); + let rl = readline.createInterface({ + input: this.in, + escapeCodeTimeout: 50, + }); + readline.emitKeypressEvents(this.in, rl); + + if (this.in.isTTY) this.in.setRawMode(true); + let isSelect = + ["SelectPrompt", "MultiSelectPrompt"].indexOf(this.constructor.name) > -1; + + let keypress = (str: string, key: ActionKey) => { + if (this.in.isTTY) this.in.setRawMode(true); + let a = action(key, isSelect); + if (a === false) { + try { + this._(str, key); + } catch (_) {} + // @ts-expect-error + } else if (typeof this[a] === "function") { + // @ts-expect-error + this[a](key); + } + }; + + this.close = () => { + this.out.write(cursor.show); + this.in.removeListener("keypress", keypress); + if (this.in.isTTY) this.in.setRawMode(false); + rl.close(); + this.emit( + this.aborted ? "abort" : this.exited ? "exit" : "submit", + // @ts-expect-error + this.value + ); + this.closed = true; + }; + + this.in.on("keypress", keypress); + } + + get type(): string { + throw new Error("Method type not implemented."); + } + + bell() { + this.out.write(beep); + } + + fire() { + this.emit("state", { + // @ts-expect-error + value: this.value, + aborted: !!this.aborted, + exited: !!this.exited, + }); + } + + render() { + this.onRender(color); + if (this.firstRender) this.firstRender = false; + } + + _(c: string, key: ActionKey) { + throw new Error("Method _ not implemented."); + } +} + +export interface PromptOptions { + stdin?: typeof process.stdin; + stdout?: typeof process.stdout; + onRender?(render: (...text: unknown[]) => string): void; + onSubmit?( + v: any + ): void | undefined | boolean | Promise; + onCancel?( + v: any + ): void | undefined | boolean | Promise; + onAbort?( + v: any + ): void | undefined | boolean | Promise; + onExit?( + v: any + ): void | undefined | boolean | Promise; + onState?( + v: any + ): void | undefined | boolean | Promise; +} diff --git a/packages/create-remix/prompts-select.ts b/packages/create-remix/prompts-select.ts new file mode 100644 index 00000000000..2a1eb4c77ac --- /dev/null +++ b/packages/create-remix/prompts-select.ts @@ -0,0 +1,221 @@ +/** + * Adapted from https://github.com/withastro/cli-kit + * @license MIT License Copyright (c) 2022 Nate Moore + */ +import { cursor, erase } from "sisteransi"; + +import { Prompt, type PromptOptions } from "./prompts-prompt-base"; +import { color, strip, clear, shouldUseAscii, type ActionKey } from "./utils"; + +export interface SelectChoice { + value: unknown; + label: string; + hint?: string; +} + +export interface SelectPromptOptions< + Choices extends Readonly[]> +> extends PromptOptions { + hint?: string; + message: string; + label: string; + initial?: Choices[number]["value"] | undefined; + validate?: (v: any) => boolean; + error?: string; + choices: Choices; +} + +export class SelectPrompt< + Choices extends Readonly[]> +> extends Prompt { + choices: Choices; + label: string; + msg: string; + hint?: string; + value: Choices[number]["value"] | undefined; + initialValue: Choices[number]["value"]; + search: string | null; + done: boolean | undefined; + cursor: number; + name = "SelectPrompt" as const; + private _timeout: NodeJS.Timeout | undefined; + + // set by render which is called in constructor + outputText!: string; + + constructor(opts: SelectPromptOptions) { + if ( + !opts.choices || + !Array.isArray(opts.choices) || + opts.choices.length < 1 + ) { + throw new Error("SelectPrompt must contain choices"); + } + super(opts); + this.label = opts.label; + this.hint = opts.hint; + this.msg = opts.message; + this.value = opts.initial; + this.choices = opts.choices; + this.initialValue = opts.initial || this.choices[0].value; + this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); + this.search = null; + this.render(); + } + + get type() { + return "select" as const; + } + + exit() { + this.abort(); + } + + abort() { + this.done = this.aborted = true; + this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + submit() { + this.value = this.value || undefined; + this.cursor = this.choices.findIndex((c) => c.value === this.value); + this.done = true; + this.aborted = false; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + delete() { + this.search = null; + this.render(); + } + + _(c: string, key: ActionKey) { + if (this._timeout) clearTimeout(this._timeout); + if (!Number.isNaN(Number.parseInt(c))) { + let n = Number.parseInt(c) - 1; + this.moveCursor(n); + this.render(); + return this.submit(); + } + this.search = this.search || ""; + this.search += c.toLowerCase(); + let choices = !this.search ? this.choices.slice(this.cursor) : this.choices; + let n = choices.findIndex((c) => + c.label.toLowerCase().includes(this.search!) + ); + if (n > -1) { + this.moveCursor(n); + this.render(); + } + this._timeout = setTimeout(() => { + this.search = null; + }, 500); + } + + moveCursor(n: number) { + this.cursor = n; + this.value = this.choices[n].value; + this.fire(); + } + + reset() { + this.moveCursor(0); + this.fire(); + this.render(); + } + + first() { + this.moveCursor(0); + this.render(); + } + + last() { + this.moveCursor(this.choices.length - 1); + this.render(); + } + + up() { + if (this.cursor === 0) { + this.moveCursor(this.choices.length - 1); + } else { + this.moveCursor(this.cursor - 1); + } + this.render(); + } + + down() { + if (this.cursor === this.choices.length - 1) { + this.moveCursor(0); + } else { + this.moveCursor(this.cursor + 1); + } + this.render(); + } + + highlight(label: string) { + if (!this.search) return label; + let n = label.toLowerCase().indexOf(this.search.toLowerCase()); + if (n === -1) return label; + return [ + label.slice(0, n), + color.underline(label.slice(n, n + this.search.length)), + label.slice(n + this.search.length), + ].join(""); + } + + render() { + if (this.closed) return; + if (this.firstRender) this.out.write(cursor.hide); + else this.out.write(clear(this.outputText, this.out.columns)); + super.render(); + + let outputText = [ + "\n", + this.label, + " ", + this.msg, + this.done + ? "" + : this.hint + ? (this.out.columns < 80 ? "\n" + " ".repeat(8) : "") + + color.dim(` (${this.hint})`) + : "", + "\n", + ]; + + let prefix = " ".repeat(strip(this.label).length); + + if (this.done) { + outputText.push( + `${prefix} `, + color.dim(`${this.choices[this.cursor]?.label}`) + ); + } else { + outputText.push( + this.choices + .map((choice, i) => + i === this.cursor + ? `${prefix} ${color.green( + shouldUseAscii() ? ">" : "●" + )} ${this.highlight(choice.label)} ${ + choice.hint ? color.dim(choice.hint) : "" + }` + : color.dim( + `${prefix} ${shouldUseAscii() ? "—" : "○"} ${choice.label} ` + ) + ) + .join("\n") + ); + } + this.outputText = outputText.join(""); + + this.out.write(erase.line + cursor.to(0) + this.outputText); + } +} diff --git a/packages/create-remix/prompts-text.ts b/packages/create-remix/prompts-text.ts new file mode 100644 index 00000000000..146ea04a6d3 --- /dev/null +++ b/packages/create-remix/prompts-text.ts @@ -0,0 +1,284 @@ +/** + * Adapted from https://github.com/withastro/cli-kit + * @license MIT License Copyright (c) 2022 Nate Moore + */ +import { cursor, erase } from "sisteransi"; + +import { Prompt, type PromptOptions } from "./prompts-prompt-base"; +import { + color, + strip, + clear, + lines, + shouldUseAscii, + type ActionKey, +} from "./utils"; + +export interface TextPromptOptions extends PromptOptions { + label: string; + message: string; + initial?: string; + style?: string; + validate?: (v: any) => v is string; + error?: string; + hint?: string; +} + +export class TextPrompt extends Prompt { + transform: { render: (v: string) => any; scale: number }; + label: string; + scale: number; + msg: string; + initial: string; + hint?: string; + validator: (v: any) => boolean | Promise; + errorMsg: string; + cursor: number; + cursorOffset: number; + clear: any; + done: boolean | undefined; + error: boolean | undefined; + red: boolean | undefined; + outputError: string | undefined; + name = "TextPrompt" as const; + + // set by value setter, value is set in constructor + _value!: string; + placeholder!: boolean; + rendered!: string; + + // set by render which is called in constructor + outputText!: string; + + constructor(opts: TextPromptOptions) { + super(opts); + this.transform = { render: (v) => v, scale: 1 }; + this.label = opts.label; + this.scale = this.transform.scale; + this.msg = opts.message; + this.hint = opts.hint; + this.initial = opts.initial || ""; + this.validator = opts.validate || (() => true); + this.value = ""; + this.errorMsg = opts.error || "Please enter a valid value"; + this.cursor = Number(!!this.initial); + this.cursorOffset = 0; + this.clear = clear(``, this.out.columns); + this.render(); + } + + get type() { + return "text" as const; + } + + set value(v: string) { + if (!v && this.initial) { + this.placeholder = true; + this.rendered = color.dim(this.initial); + } else { + this.placeholder = false; + this.rendered = this.transform.render(v); + } + this._value = v; + this.fire(); + } + + get value() { + return this._value; + } + + reset() { + this.value = ""; + this.cursor = Number(!!this.initial); + this.cursorOffset = 0; + this.fire(); + this.render(); + } + + exit() { + this.abort(); + } + + abort() { + this.value = this.value || this.initial; + this.done = this.aborted = true; + this.error = false; + this.red = false; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + async validate() { + let valid = await this.validator(this.value); + if (typeof valid === `string`) { + this.errorMsg = valid; + valid = false; + } + this.error = !valid; + } + + async submit() { + this.value = this.value || this.initial; + this.cursorOffset = 0; + this.cursor = this.rendered.length; + await this.validate(); + if (this.error) { + this.red = true; + this.fire(); + this.render(); + return; + } + this.done = true; + this.aborted = false; + this.fire(); + this.render(); + this.out.write("\n"); + this.close(); + } + + next() { + if (!this.placeholder) return this.bell(); + this.value = this.initial; + this.cursor = this.rendered.length; + this.fire(); + this.render(); + } + + moveCursor(n: number) { + if (this.placeholder) return; + this.cursor = this.cursor + n; + this.cursorOffset += n; + } + + _(c: string, key: ActionKey) { + let s1 = this.value.slice(0, this.cursor); + let s2 = this.value.slice(this.cursor); + this.value = `${s1}${c}${s2}`; + this.red = false; + this.cursor = this.placeholder ? 0 : s1.length + 1; + this.render(); + } + + delete() { + if (this.isCursorAtStart()) return this.bell(); + let s1 = this.value.slice(0, this.cursor - 1); + let s2 = this.value.slice(this.cursor); + this.value = `${s1}${s2}`; + this.red = false; + this.outputError = ""; + this.error = false; + if (this.isCursorAtStart()) { + this.cursorOffset = 0; + } else { + this.cursorOffset++; + this.moveCursor(-1); + } + this.render(); + } + + deleteForward() { + if (this.cursor * this.scale >= this.rendered.length || this.placeholder) + return this.bell(); + let s1 = this.value.slice(0, this.cursor); + let s2 = this.value.slice(this.cursor + 1); + this.value = `${s1}${s2}`; + this.red = false; + this.outputError = ""; + this.error = false; + if (this.isCursorAtEnd()) { + this.cursorOffset = 0; + } else { + this.cursorOffset++; + } + this.render(); + } + + first() { + this.cursor = 0; + this.render(); + } + + last() { + this.cursor = this.value.length; + this.render(); + } + + left() { + if (this.cursor <= 0 || this.placeholder) return this.bell(); + this.moveCursor(-1); + this.render(); + } + + right() { + if (this.cursor * this.scale >= this.rendered.length || this.placeholder) + return this.bell(); + this.moveCursor(1); + this.render(); + } + + isCursorAtStart() { + return this.cursor === 0 || (this.placeholder && this.cursor === 1); + } + + isCursorAtEnd() { + return ( + this.cursor === this.rendered.length || + (this.placeholder && this.cursor === this.rendered.length + 1) + ); + } + + render() { + if (this.closed) return; + if (!this.firstRender) { + if (this.outputError) + this.out.write( + cursor.down(lines(this.outputError, this.out.columns) - 1) + + clear(this.outputError, this.out.columns) + ); + this.out.write(clear(this.outputText, this.out.columns)); + } + super.render(); + this.outputError = ""; + + let prefix = " ".repeat(strip(this.label).length); + + this.outputText = [ + "\n", + this.label, + " ", + this.msg, + this.done + ? "" + : this.hint + ? (this.out.columns < 80 ? "\n" + " ".repeat(8) : "") + + color.dim(` (${this.hint})`) + : "", + "\n" + prefix, + " ", + this.done ? color.dim(this.rendered) : this.rendered, + ].join(""); + + if (this.error) { + this.outputError += ` ${color.redBright( + (shouldUseAscii() ? "> " : "▶ ") + this.errorMsg + )}`; + } + + this.out.write( + erase.line + + cursor.to(0) + + this.outputText + + cursor.save + + this.outputError + + cursor.restore + + cursor.move( + this.placeholder + ? (this.rendered.length - 9) * -1 + : this.cursorOffset, + 0 + ) + ); + } +} diff --git a/packages/create-remix/remix-version.ts b/packages/create-remix/remix-version.ts new file mode 100644 index 00000000000..ab47da4a8ec --- /dev/null +++ b/packages/create-remix/remix-version.ts @@ -0,0 +1,27 @@ +import https from "node:https"; + +let _versionCache: string | null = null; +export async function getLatestRemixVersion() { + return new Promise((resolve, reject) => { + if (_versionCache) { + return resolve(_versionCache); + } + let request = https.get( + "https://registry.npmjs.org/remix/latest", + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + let { version } = JSON.parse(body); + _versionCache = version; + resolve(version); + }); + } + ); + + // set a short timeout to avoid super slow initiation + request.setTimeout(5000); + + request.on("error", (err) => reject(err)); + }); +} diff --git a/packages/create-remix/rollup.config.js b/packages/create-remix/rollup.config.js index 3f28293d065..19cd6b5a40e 100644 --- a/packages/create-remix/rollup.config.js +++ b/packages/create-remix/rollup.config.js @@ -1,7 +1,62 @@ -const { getCliConfig } = require("../../rollup.utils"); +const path = require("path"); +const json = require("@rollup/plugin-json").default; +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); const { name: packageName, version } = require("./package.json"); /** @returns {import("rollup").RollupOptions[]} */ module.exports = function rollup() { - return [getCliConfig({ packageName, version })]; + let sourceDir = "packages/create-remix"; + let outputDir = getOutputDir(packageName); + let outputDist = path.join(outputDir, "dist"); + + return [ + { + external(id) { + return isBareModuleId(id); + }, + input: `${sourceDir}/cli.ts`, + output: { + banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "named", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + json(), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [ + { src: "LICENSE.md", dest: [outputDir, sourceDir] }, + { src: `${sourceDir}/package.json`, dest: outputDir }, + { src: `${sourceDir}/README.md`, dest: outputDir }, + ], + }), + // Allow dynamic imports in CJS code to allow us to utilize + // ESM modules as part of the compiler. + { + name: "dynamic-import-polyfill", + renderDynamicImport() { + return { + left: "import(", + right: ")", + }; + }, + }, + ], + }, + ]; }; diff --git a/packages/create-remix/tsconfig.json b/packages/create-remix/tsconfig.json index c5fe36ae1b2..8f869b8dd8e 100644 --- a/packages/create-remix/tsconfig.json +++ b/packages/create-remix/tsconfig.json @@ -1,6 +1,6 @@ { - "include": ["**/*.ts"], - "exclude": ["dist", "**/node_modules/**"], + "include": ["**/*.ts", "./package.json"], + "exclude": ["dist", "**/node_modules/**", "__tests__"], "compilerOptions": { "lib": ["ES2019", "DOM", "DOM.Iterable"], "target": "ES2019", diff --git a/packages/create-remix/utils.ts b/packages/create-remix/utils.ts new file mode 100644 index 00000000000..8e4c40594d2 --- /dev/null +++ b/packages/create-remix/utils.ts @@ -0,0 +1,268 @@ +import process from "node:process"; +import os from "node:os"; +import fs from "node:fs"; +import { type Key as ActionKey } from "node:readline"; +import { erase, cursor } from "sisteransi"; +import chalk from "chalk"; + +// https://no-color.org/ +const SUPPORTS_COLOR = chalk.supportsColor && !process.env.NO_COLOR; + +export const color = { + supportsColor: SUPPORTS_COLOR, + heading: safeColor(chalk.bold), + arg: safeColor(chalk.yellowBright), + error: safeColor(chalk.red), + warning: safeColor(chalk.yellow), + hint: safeColor(chalk.blue), + bold: safeColor(chalk.bold), + black: safeColor(chalk.black), + white: safeColor(chalk.white), + blue: safeColor(chalk.blue), + cyan: safeColor(chalk.cyan), + red: safeColor(chalk.red), + yellow: safeColor(chalk.yellow), + green: safeColor(chalk.green), + blackBright: safeColor(chalk.blackBright), + whiteBright: safeColor(chalk.whiteBright), + blueBright: safeColor(chalk.blueBright), + cyanBright: safeColor(chalk.cyanBright), + redBright: safeColor(chalk.redBright), + yellowBright: safeColor(chalk.yellowBright), + greenBright: safeColor(chalk.greenBright), + bgBlack: safeColor(chalk.bgBlack), + bgWhite: safeColor(chalk.bgWhite), + bgBlue: safeColor(chalk.bgBlue), + bgCyan: safeColor(chalk.bgCyan), + bgRed: safeColor(chalk.bgRed), + bgYellow: safeColor(chalk.bgYellow), + bgGreen: safeColor(chalk.bgGreen), + bgBlackBright: safeColor(chalk.bgBlackBright), + bgWhiteBright: safeColor(chalk.bgWhiteBright), + bgBlueBright: safeColor(chalk.bgBlueBright), + bgCyanBright: safeColor(chalk.bgCyanBright), + bgRedBright: safeColor(chalk.bgRedBright), + bgYellowBright: safeColor(chalk.bgYellowBright), + bgGreenBright: safeColor(chalk.bgGreenBright), + gray: safeColor(chalk.gray), + dim: safeColor(chalk.dim), + reset: safeColor(chalk.reset), + inverse: safeColor(chalk.inverse), + hex: (color: string) => safeColor(chalk.hex(color)), + underline: chalk.underline, +}; + +function safeColor(style: chalk.Chalk) { + return SUPPORTS_COLOR ? style : identity; +} + +export { type ActionKey }; + +const unicode = { enabled: os.platform() !== "win32" }; +export const shouldUseAscii = () => !unicode.enabled; + +export function isInteractive() { + // Support explicit override for testing purposes + if ("CREATE_REMIX_FORCE_INTERACTIVE" in process.env) { + return true; + } + + // Adapted from https://github.com/sindresorhus/is-interactive + return Boolean( + process.stdout.isTTY && + process.env.TERM !== "dumb" && + !("CI" in process.env) + ); +} + +export function log(message: string) { + return process.stdout.write(message + "\n"); +} + +export let stderr = process.stderr; +/** @internal Used to mock `process.stderr.write` for testing purposes */ +export function setStderr(writable: typeof process.stderr) { + stderr = writable; +} + +export function logError(message: string) { + return stderr.write(message + "\n"); +} + +export function info(prefix: string, text?: string | string[]) { + let textParts = Array.isArray(text) ? text : [text || ""].filter(Boolean); + let formattedText = textParts.map((textPart) => color.dim(textPart)).join(""); + + if (process.stdout.columns < 80) { + log(`${" ".repeat(5)} ${color.cyan("◼")} ${color.cyan(prefix)}`); + log(`${" ".repeat(9)}${formattedText}`); + } else { + log( + `${" ".repeat(5)} ${color.cyan("◼")} ${color.cyan( + prefix + )} ${formattedText}` + ); + } +} + +export function success(text: string) { + log(`${" ".repeat(5)} ${color.green("✔")} ${color.green(text)}`); +} + +export function error(prefix: string, text?: string | string[]) { + let textParts = Array.isArray(text) ? text : [text || ""].filter(Boolean); + let formattedText = textParts + .map((textPart) => color.error(textPart)) + .join(""); + + log(""); + + if (process.stdout.columns < 80) { + logError(`${" ".repeat(5)} ${color.red("▲")} ${color.red(prefix)}`); + logError(`${" ".repeat(9)}${formattedText}`); + } else { + logError( + `${" ".repeat(5)} ${color.red("▲")} ${color.red( + prefix + )} ${formattedText}` + ); + } +} + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function toValidProjectName(projectName: string) { + if (isValidProjectName(projectName)) { + return projectName; + } + return projectName + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/^[._]/, "") + .replace(/[^a-z\d\-~]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, ""); +} + +function isValidProjectName(projectName: string) { + return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( + projectName + ); +} + +export function identity(v: V) { + return v; +} + +export function strip(str: string) { + let pattern = [ + "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", + "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))", + ].join("|"); + let RGX = new RegExp(pattern, "g"); + return typeof str === "string" ? str.replace(RGX, "") : str; +} + +export function reverse(arr: T[]): T[] { + return [...arr].reverse(); +} + +export function isValidJsonObject(obj: any): obj is Record { + return !!(obj && typeof obj === "object" && !Array.isArray(obj)); +} + +export async function directoryExists(p: string) { + try { + let stat = await fs.promises.stat(p); + return stat.isDirectory(); + } catch { + return false; + } +} + +export async function fileExists(p: string) { + try { + let stat = await fs.promises.stat(p); + return stat.isFile(); + } catch { + return false; + } +} + +export async function ensureDirectory(dir: string) { + if (!(await directoryExists(dir))) { + await fs.promises.mkdir(dir, { recursive: true }); + } +} + +export function pathContains(path: string, dir: string) { + let relative = path.replace(dir, ""); + return relative.length < path.length && !relative.startsWith(".."); +} + +export function isUrl(value: string | URL) { + try { + new URL(value); + return true; + } catch (_) { + return false; + } +} + +export function clear(prompt: string, perLine: number) { + if (!perLine) return erase.line + cursor.to(0); + let rows = 0; + let lines = prompt.split(/\r?\n/); + for (let line of lines) { + rows += 1 + Math.floor(Math.max(strip(line).length - 1, 0) / perLine); + } + + return erase.lines(rows); +} + +export function lines(msg: string, perLine: number) { + let lines = String(strip(msg) || "").split(/\r?\n/); + if (!perLine) return lines.length; + return lines + .map((l) => Math.ceil(l.length / perLine)) + .reduce((a, b) => a + b); +} + +export function action(key: ActionKey, isSelect: boolean) { + if (key.meta && key.name !== "escape") return; + + if (key.ctrl) { + if (key.name === "a") return "first"; + if (key.name === "c") return "abort"; + if (key.name === "d") return "abort"; + if (key.name === "e") return "last"; + if (key.name === "g") return "reset"; + } + + if (isSelect) { + if (key.name === "j") return "down"; + if (key.name === "k") return "up"; + } + + if (key.name === "return") return "submit"; + if (key.name === "enter") return "submit"; // ctrl + J + if (key.name === "backspace") return "delete"; + if (key.name === "delete") return "deleteForward"; + if (key.name === "abort") return "abort"; + if (key.name === "escape") return "exit"; + if (key.name === "tab") return "next"; + if (key.name === "pagedown") return "nextPage"; + if (key.name === "pageup") return "prevPage"; + if (key.name === "home") return "home"; + if (key.name === "end") return "end"; + + if (key.name === "up") return "up"; + if (key.name === "down") return "down"; + if (key.name === "right") return "right"; + if (key.name === "left") return "left"; + + return false; +} diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts index cd744cffd54..71385829fb2 100644 --- a/packages/remix-dev/__tests__/cli-test.ts +++ b/packages/remix-dev/__tests__/cli-test.ts @@ -5,11 +5,6 @@ import util from "util"; import fse from "fs-extra"; import semver from "semver"; -import { jestTimeout } from "./setupAfterEnv"; - -let DOWN = "\x1B\x5B\x42"; -let ENTER = "\x0D"; - let execFile = util.promisify(childProcess.execFile); const TEMP_DIR = path.join( @@ -37,8 +32,6 @@ async function execRemix( [ "--require", require.resolve("esbuild-register"), - "--require", - path.join(__dirname, "./msw.ts"), path.resolve(__dirname, "../cli.ts"), ...args, ], @@ -62,8 +55,6 @@ async function execRemix( [ "--require", require.resolve("esbuild-register"), - "--require", - path.join(__dirname, "./msw.ts"), path.resolve(__dirname, "../cli.ts"), ...args, ], @@ -92,7 +83,6 @@ describe("remix CLI", () => { "R E M I X Usage: - $ remix create --template