Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(remix-dev): use auto-detected package manager #2562

Merged
merged 10 commits into from
Apr 12, 2022
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
- hzhu
- IAmLuisJ
- ianduvall
- illright
- imzshh
- isaacrmoreno
- ishan-me
Expand Down
115 changes: 110 additions & 5 deletions packages/remix-dev/__tests__/create-test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
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";

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(() => ({
Expand All @@ -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)}`
Expand Down Expand Up @@ -91,6 +105,7 @@ describe("the create command", () => {

beforeEach(() => {
process.chdir(TEMP_DIR);
jest.clearAllMocks();
});

afterEach(async () => {
Expand Down Expand Up @@ -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;
});
});

/*
Expand Down
14 changes: 11 additions & 3 deletions packages/remix-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ export async function create({
projectDir,
remixVersion,
installDeps,
packageManager,
useTypeScript,
githubToken,
}: {
appTemplate: string;
projectDir: string;
remixVersion?: string;
installDeps: boolean;
packageManager: "npm" | "yarn" | "pnpm";
useTypeScript: boolean;
githubToken?: string;
}) {
Expand All @@ -45,22 +47,28 @@ export async function create({
projectDir,
remixVersion,
installDeps,
packageManager,
useTypeScript,
githubToken,
});
spinner.stop();
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 });
Expand Down
20 changes: 14 additions & 6 deletions packages/remix-dev/cli/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface CreateAppArgs {
projectDir: string;
remixVersion?: string;
installDeps: boolean;
packageManager: "npm" | "yarn" | "pnpm";
useTypeScript: boolean;
githubToken?: string;
}
Expand All @@ -30,6 +31,7 @@ export async function createApp({
projectDir,
remixVersion = remixDevPackageVersion,
installDeps,
packageManager,
useTypeScript = true,
githubToken = process.env.GITHUB_TOKEN,
}: CreateAppArgs) {
Expand Down Expand Up @@ -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,
});
}
}

Expand Down
35 changes: 30 additions & 5 deletions packages/remix-dev/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
},
Expand All @@ -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."
)
);
Expand All @@ -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,
});
Expand Down Expand Up @@ -355,15 +377,15 @@ 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();
console.log(
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();
Expand All @@ -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");
Expand Down