Repository: remix-run/remix (ts)

Issue:
[Feature]: Detect if user us running `yarn` and use `yarn install` instead of `npm install`
### What is the new or updated feature that you are suggesting?

When creating a remix project,`create-remix` should detect if user is using `yarn`.

 If user uses `yarn`:
- it should ask "Do you want me to run 'yarn install'?" instead of the default "Do you want me to run 'npm install'"
- Use `yarn install` to install such dependencie

### Why should this feature be included?

For `yarn` users:
- It is a bit odd to ask "Do you want me to run 'npm install'" when you are creating the project using yarn.
- Using `yarn` to install the dependencies lets remix generate yarn's correct lock files (`yarn.lock` instead of `package-lock.json`)

Pull request:
feat(remix-dev): use auto-detected package manager
- [ ] Docs
- [x] Tests

This PR introduces detection of the preferred package manager by looking at the `npm_user_agent` environment variable that all popular package managers set (`npm`, `yarn`, `pnpm`).

~~I need some assistance with testing — I'm not sure how to mock the calls to `execSync("pnpm install", ...)`, since we're spawning another Node process that calls this function. For now, I marked these tests to be skipped.~~ UPD: resolved!

Closes #1098

Patch:
diff --git a/contributors.yml b/contributors.yml
index 4f1ed6bb4..54b907d22 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -119,6 +119,7 @@
 - hzhu
 - IAmLuisJ
 - ianduvall
+- illright
 - imzshh
 - isaacrmoreno
 - ishan-me
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index ebac2d787..10f616751 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -29,6 +29,7 @@ export async function create({
   projectDir,
   remixVersion,
   installDeps,
+  packageManager,
   useTypeScript,
   githubToken,
 }: {
@@ -36,6 +37,7 @@ export async function create({
   projectDir: string;
   remixVersion?: string;
   installDeps: boolean;
+  packageManager: "npm" | "yarn" | "pnpm";
   useTypeScript: boolean;
   githubToken?: string;
 }) {
@@ -45,6 +47,7 @@ export async function create({
     projectDir,
     remixVersion,
     installDeps,
+    packageManager,
     useTypeScript,
     githubToken,
   });
@@ -52,15 +55,20 @@ export async function create({
   spinner.clear();
 }
 
-export async function init(projectDir: string) {
+export async function init(
+  projectDir: string,
+  packageManager: "npm" | "yarn" | "pnpm"
+) {
   let initScriptDir = path.join(projectDir, "remix.init");
   let initScript = path.resolve(initScriptDir, "index.js");
 
   let isTypeScript = fse.existsSync(path.join(projectDir, "tsconfig.json"));
 
   if (await fse.pathExists(initScript)) {
-    // TODO: check for npm/yarn/pnpm
-    execSync("npm install", { stdio: "ignore", cwd: initScriptDir });
+    execSync(`${packageManager} install`, {
+      stdio: "ignore",
+      cwd: initScriptDir,
+    });
     let initFn = require(initScript);
     try {
       await initFn({ rootDirectory: projectDir, isTypeScript });
diff --git a/packages/remix-dev/cli/create.ts b/packages/remix-dev/cli/create.ts
index c0e86bbd7..48e794d41 100644
--- a/packages/remix-dev/cli/create.ts
+++ b/packages/remix-dev/cli/create.ts
@@ -21,6 +21,7 @@ interface CreateAppArgs {
   projectDir: string;
   remixVersion?: string;
   installDeps: boolean;
+  packageManager: "npm" | "yarn" | "pnpm";
   useTypeScript: boolean;
   githubToken?: string;
 }
@@ -30,6 +31,7 @@ export async function createApp({
   projectDir,
   remixVersion = remixDevPackageVersion,
   installDeps,
+  packageManager,
   useTypeScript = true,
   githubToken = process.env.GITHUB_TOKEN,
 }: CreateAppArgs) {
@@ -148,18 +150,24 @@ export async function createApp({
   }
 
   if (installDeps) {
-    // TODO: use yarn/pnpm/npm
-    let npmConfig = execSync("npm config get @remix-run:registry", {
-      encoding: "utf8",
-    });
+    let npmConfig = execSync(
+      `${packageManager} config get @remix-run:registry`,
+      {
+        encoding: "utf8",
+      }
+    );
     if (npmConfig?.startsWith("https://npm.remix.run")) {
       throw Error(
         "🚨 Oops! You still have the private Remix registry configured. Please " +
-          "run `npm config delete @remix-run:registry` or edit your .npmrc file " +
+          `run \`${packageManager} config delete @remix-run:registry\` or edit your .npmrc file ` +
           "to remove it."
       );
     }
-    execSync("npm install", { stdio: "inherit", cwd: projectDir });
+
+    execSync(`${packageManager} install`, {
+      stdio: "inherit",
+      cwd: projectDir,
+    });
   }
 }
 
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index 640592d80..9327d7cf2 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -12,6 +12,20 @@ import * as commands from "./commands";
 import { convertTemplateToJavaScript } from "./convert-to-javascript";
 import { validateNewProjectPath, validateTemplate } from "./create";
 
+/**
+ * Determine which package manager the user prefers.
+ *
+ * npm, Yarn and pnpm set the user agent environment variable
+ * that can be used to determine which package manager ran
+ * the command.
+ */
+function getPreferredPackageManager() {
+  return ((process.env.npm_user_agent ?? "").split("/")[0] || "npm") as
+    | "npm"
+    | "yarn"
+    | "pnpm";
+}
+
 const helpText = `
 ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
   "M"
@@ -118,6 +132,12 @@ const templateChoices = [
   { name: "Cloudflare Workers", value: "cloudflare-workers" },
 ];
 
+const npxInterop = {
+  npm: "npx",
+  yarn: "yarn",
+  pnpm: "pnpm exec",
+};
+
 /**
  * Programmatic interface for running the Remix CLI with the given command line
  * arguments.
@@ -222,6 +242,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
         return;
       }
 
+      let pm = getPreferredPackageManager();
       let answers = await inquirer
         .prompt<{
           appType: "template" | "stack";
@@ -286,7 +307,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
           {
             name: "install",
             type: "confirm",
-            message: "Do you want me to run `npm install`?",
+            message: `Do you want me to run \`${pm} install\`?`,
             when() {
               return flags.install === undefined;
             },
@@ -300,7 +321,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
                 "🚨 Your terminal doesn't support interactivity; using default " +
                   "configuration.\n\n" +
                   "If you'd like to use different settings, try passing them " +
-                  "as arguments. Run `npx create-remix@latest --help` to see " +
+                  `as arguments. Run \`${pm} create remix@latest --help\` to see ` +
                   "available options."
               )
             );
@@ -321,6 +342,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
         projectDir,
         remixVersion: flags.remixVersion,
         installDeps,
+        packageManager: pm,
         useTypeScript: flags.typescript !== false,
         githubToken: process.env.GITHUB_TOKEN,
       });
@@ -355,7 +377,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       if (hasInitScript) {
         if (installDeps) {
           console.log("💿 Running remix.init script");
-          await commands.init(projectDir);
+          await commands.init(projectDir, pm);
           await fse.remove(initScriptDir);
         } else {
           console.log();
@@ -363,7 +385,7 @@ export async function run(argv: string[] = process.argv.slice(2)) {
             colors.warning(
               "💿 You've opted out of installing dependencies so we won't run the " +
                 "remix.init/index.js script for you just yet. Once you've installed " +
-                "dependencies, you can run it manually with `npx remix init`"
+                `dependencies, you can run it manually with \`${npxInterop[pm]} remix init\``
             )
           );
           console.log();
@@ -389,7 +411,10 @@ export async function run(argv: string[] = process.argv.slice(2)) {
       break;
     }
     case "init":
-      await commands.init(input[1] || process.env.REMIX_ROOT || process.cwd());
+      await commands.init(
+        input[1] || process.env.REMIX_ROOT || process.cwd(),
+        getPreferredPackageManager()
+      );
       break;
     case "routes":
       await commands.routes(input[1], flags.json ? "json" : "jsx");


Test patch:
diff --git a/packages/remix-dev/__tests__/create-test.ts b/packages/remix-dev/__tests__/create-test.ts
index 60cfc2b44..87d9bd9e9 100644
--- a/packages/remix-dev/__tests__/create-test.ts
+++ b/packages/remix-dev/__tests__/create-test.ts
@@ -1,8 +1,10 @@
+import { execSync } from 'child_process';
 import fse from "fs-extra";
 import os from "os";
 import path from "path";
 import { pathToFileURL } from "url";
 import stripAnsi from "strip-ansi";
+import inquirer from "inquirer";
 
 import { run } from "../cli/run";
 import { server } from "./msw";
@@ -10,6 +12,9 @@ import { server } from "./msw";
 beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
 afterAll(() => server.close());
 
+const yarnUserAgent = "yarn/1.22.18 npm/? node/v14.17.0 linux x64";
+const pnpmUserAgent = "pnpm/6.32.3 npm/? node/v14.17.0 linux x64";
+
 // keep the console clear
 jest.mock("ora", () => {
   return jest.fn(() => ({
@@ -20,24 +25,33 @@ jest.mock("ora", () => {
   }));
 });
 
-// this is so we can mock execSync for "npm install"
+// this is so we can mock execSync for "npm install" and the like
 jest.mock("child_process", () => {
   let cp = jest.requireActual(
     "child_process"
   ) as typeof import("child_process");
+  let installDepsCmdPattern = /^(npm|yarn|pnpm) install$/;
+  let configGetCmdPattern = /^(npm|yarn|pnpm) config get/;
+
   return {
     ...cp,
-    execSync(command: string, options: Parameters<typeof cp.execSync>[1]) {
+    execSync: jest.fn((command: string, options: Parameters<typeof cp.execSync>[1]) => {
       // this prevents us from having to run the install process
       // and keeps our console output clean
-      if (command.startsWith("npm install")) {
-        return { stdout: "mocked", stderr: "mocked" };
+      if (installDepsCmdPattern.test(command) || configGetCmdPattern.test(command)) {
+        return 'sample stdout';
       }
       return cp.execSync(command, options);
-    },
+    }),
   };
 });
 
+// this is so we can verify the prompts for the users
+jest.mock('inquirer', () => {
+  let inquirerActual = jest.requireActual('inquirer');
+  return { ...inquirerActual, prompt: jest.fn().mockImplementation(inquirerActual.prompt) }
+});
+
 const TEMP_DIR = path.join(
   fse.realpathSync(os.tmpdir()),
   `remix-tests-${Math.random().toString(32).slice(2)}`
@@ -91,6 +105,7 @@ describe("the create command", () => {
 
   beforeEach(() => {
     process.chdir(TEMP_DIR);
+    jest.clearAllMocks();
   });
 
   afterEach(async () => {
@@ -419,6 +434,96 @@ describe("the create command", () => {
     expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy();
     // deps can take a bit to install
   });
+
+  it("recognizes when Yarn was used to run the command", async () => {
+    let originalUserAgent = process.env.npm_user_agent;
+    process.env.npm_user_agent = yarnUserAgent;
+
+    let projectDir = await getProjectDir("yarn-create");
+    await run([
+      "create",
+      projectDir,
+      "--template",
+      path.join(__dirname, "fixtures", "successful-remix-init.tar.gz"),
+      "--install",
+      "--typescript",
+    ]);
+
+    expect(execSync).toBeCalledWith(
+      "yarn install",
+      expect.anything()
+    );
+    process.env.npm_user_agent = originalUserAgent;
+  });
+
+  it("recognizes when pnpm was used to run the command", async () => {
+    let originalUserAgent = process.env.npm_user_agent;
+    process.env.npm_user_agent = pnpmUserAgent;
+
+    let projectDir = await getProjectDir("pnpm-create");
+    await run([
+      "create",
+      projectDir,
+      "--template",
+      path.join(__dirname, "fixtures", "successful-remix-init.tar.gz"),
+      "--install",
+      "--typescript",
+    ]);
+
+    expect(execSync).toBeCalledWith(
+      "pnpm install",
+      expect.anything()
+    );
+    process.env.npm_user_agent = originalUserAgent;
+  });
+
+  it("prompts to run the install command for the preferred package manager", async () => {
+    let originalUserAgent = process.env.npm_user_agent;
+    process.env.npm_user_agent = pnpmUserAgent;
+
+    let projectDir = await getProjectDir("pnpm-prompt-install");
+    let mockPrompt = jest.mocked(inquirer.prompt);
+    mockPrompt.mockImplementationOnce(() => {
+      return Promise.resolve({
+        install: false,
+      }) as unknown as ReturnType<typeof inquirer.prompt>;
+    });
+
+    await run([
+      "create",
+      projectDir,
+      "--template",
+      "grunge-stack",
+      "--typescript",
+    ]);
+
+    let lastCallArgs = mockPrompt.mock.calls.at(-1)[0]
+    expect((lastCallArgs as Array<unknown>).at(-1)).toHaveProperty(
+      "message",
+      "Do you want me to run `pnpm install`?"
+    );
+    process.env.npm_user_agent = originalUserAgent;
+  });
+
+  it("suggests to run the init command with the preferred package manager", async () => {
+    let originalUserAgent = process.env.npm_user_agent;
+    process.env.npm_user_agent = pnpmUserAgent;
+
+    let projectDir = await getProjectDir("pnpm-suggest-install");
+    let mockPrompt = jest.mocked(inquirer.prompt);
+    mockPrompt.mockImplementationOnce(() => {
+      return Promise.resolve({
+        install: false,
+      }) as unknown as ReturnType<typeof inquirer.prompt>;
+    });
+
+    await run(["create", projectDir, "--template", "grunge-stack", "--no-install", "--typescript"]);
+
+    expect(output).toContain(
+      "💿 You've opted out of installing dependencies so we won't run the remix.init/index.js script for you just yet. Once you've installed dependencies, you can run it manually with `pnpm exec remix init`"
+    );
+    process.env.npm_user_agent = originalUserAgent;
+  });
 });
 
 /*